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

View File

@@ -219,25 +219,10 @@ export interface ConversationMessage {
diagram?: Diagram | null; diagram?: Diagram | null;
} }
/* ─── شکلِ پاسخِ دستیار (بک‌اند: rag/services/diagram.py) ─────────────────── */ /* ─── شکلِ پاسخِ دستیار: شِمایش در src/diagram/types.ts ─────────────────── */
export type DiagramRef = string | [number, number]; import type { DiagramSpec } from '@/diagram/types';
export type DiagramPointDef = [number, number] | { polar: [number, number]; from?: DiagramRef }; export type Diagram = DiagramSpec;
export type DiagramItem =
| { type: 'circle'; center: DiagramRef; r: number; label?: string }
| { type: 'segment' | 'arrow'; from: DiagramRef; to: DiagramRef; label?: string }
| { type: 'line'; through: [DiagramRef, DiagramRef]; label?: string }
| { type: 'angle' | 'rightAngle'; vertex: DiagramRef; from: DiagramRef; to: DiagramRef; label?: string }
| { type: 'polygon'; points: DiagramRef[]; label?: string }
| { type: 'point'; point: DiagramRef; label?: string }
| { type: 'function'; expr: string; from: number; to: number; label?: string }
| { type: 'text'; at: [number, number]; text: string };
export type Diagram =
| { kind: 'plane'; title: string; axes: boolean; points: Record<string, DiagramPointDef>; items: DiagramItem[] }
| { kind: 'numberline'; title: string; min: number; max: number; points: { at: number; label: string }[]; jumps: { from: number; to: number; label: string }[] }
| { kind: 'fraction'; title: string; shape: 'circle' | 'rect'; parts: number; shaded: number };
export interface NotificationItem { export interface NotificationItem {
id: number; id: number;

View File

@@ -1,392 +0,0 @@
import { memo, useMemo, type ReactNode } from 'react';
import type { Diagram as DiagramSpec, DiagramItem, DiagramPointDef, DiagramRef } from '@/api';
import { parseExpr } from '@/lib/mathExpr';
import { toFa } from '@/lib/format';
/**
* شکلِ پاسخِ دستیار، از روی توصیفِ JSONی که بک‌اند اعتبارسنجی کرده
* (rag/services/diagram.py). مختصات همین‌جا حساب می‌شوند — مدل فقط می‌گوید
* «نقطه روی دایره با زاویه‌ی ۳۰°» — و همه‌چیز عنصرِ SVGِ React است، بدونِ
* innerHTML؛ پس متنِ مدل هیچ راهی به HTMLِ صفحه ندارد.
*
* هر ایرادی (نقطه‌ی ناشناخته، عبارتِ نامعتبر) فقط همان تکه را حذف می‌کند؛ اگر
* چیزی برای کشیدن نماند، هیچ شکلی نشان داده نمی‌شود.
*/
const INK = '#2b2b45';
const MAIN = '#4C5FD5';
const ACCENT = '#ED6956';
const SOFT = '#9e9ec0';
const W = 300;
const PAD = 22;
type XY = [number, number];
function DiagramBase({ spec }: { spec: DiagramSpec }) {
const body = useMemo(() => {
try {
if (spec.kind === 'numberline') return <NumberLine spec={spec} />;
if (spec.kind === 'fraction') return <Fraction spec={spec} />;
if (spec.kind === 'plane') return <Plane spec={spec} />;
} catch {
/* شکلِ خراب نباید پاسخ را خراب کند */
}
return null;
}, [spec]);
if (!body) return null;
return (
// dir=ltr: محور و مختصات چپ‌به‌راست‌اند؛ در زمینه‌ی rtl «−۱» به «۱−» برمی‌گشت.
// برچسب‌های فارسی با unicode-bidi: plaintext جهتِ خودشان را می‌گیرند.
<figure dir="ltr" className="mt-2 overflow-hidden rounded-2xl border border-line bg-[#fbfbff] p-2">
{body}
{spec.title && <figcaption dir="rtl" className="mt-1 text-center text-[11px] font-bold text-muted">{spec.title}</figcaption>}
</figure>
);
}
export const Diagram = memo(DiagramBase);
/* ─── صفحه‌ی مختصات / هندسه ───────────────────────────────────────────────── */
function Plane({ spec }: { spec: Extract<DiagramSpec, { kind: 'plane' }> }) {
// نقطه‌ها: polar نسبت به مبدأ یا نقطه‌ی from، با حلِ ارجاعِ زنجیره‌ای.
const named = new Map<string, XY>();
const resolving = new Set<string>();
const resolveName = (name: string): XY | null => {
if (named.has(name)) return named.get(name)!;
const def: DiagramPointDef | undefined = spec.points[name];
if (!def || resolving.has(name)) return null;
resolving.add(name);
let xy: XY | null = null;
if (Array.isArray(def)) xy = [def[0], def[1]];
else {
const origin = def.from !== undefined ? ref(def.from) : [0, 0];
if (origin) {
const [r, deg] = def.polar;
const t = (deg * Math.PI) / 180;
xy = [origin[0] + r * Math.cos(t), origin[1] + r * Math.sin(t)];
}
}
resolving.delete(name);
if (xy) named.set(name, xy);
return xy;
};
const ref = (r: DiagramRef): XY | null => (typeof r === 'string' ? resolveName(r) : [r[0], r[1]]);
Object.keys(spec.points).forEach(resolveName);
// نمودارِ تابع‌ها پیش از کادر، چون کادر را تعیین می‌کنند.
const curves: { item: Extract<DiagramItem, { type: 'function' }>; pts: XY[] }[] = [];
for (const item of spec.items) {
if (item.type !== 'function') continue;
try {
const f = parseExpr(item.expr);
const pts: XY[] = [];
for (let i = 0; i <= 160; i++) {
const x = item.from + ((item.to - item.from) * i) / 160;
const y = f(x);
if (Number.isFinite(y)) pts.push([x, y]);
}
if (pts.length > 1) curves.push({ item, pts });
} catch {
/* عبارتِ نامعتبر: همین نمودار کشیده نمی‌شود */
}
}
// کادر
const xs: number[] = [], ys: number[] = [];
const add = ([x, y]: XY) => { xs.push(x); ys.push(y); };
named.forEach(add);
for (const item of spec.items) {
if (item.type === 'circle') {
const c = ref(item.center);
if (c) { add([c[0] - item.r, c[1] - item.r]); add([c[0] + item.r, c[1] + item.r]); }
} else if (item.type === 'text') add(item.at);
else if (item.type === 'segment' || item.type === 'arrow') {
const a = ref(item.from), b = ref(item.to);
if (a) add(a);
if (b) add(b);
} else if (item.type === 'polygon') item.points.forEach((p) => { const xy = ref(p); if (xy) add(xy); });
else if (item.type === 'point') { const xy = ref(item.point); if (xy) add(xy); }
}
for (const { item, pts } of curves) {
xs.push(item.from, item.to);
const cy = pts.map((p) => p[1]).sort((a, b) => a - b);
// دُمِ نمودار (مثلاً tan) نباید کلِ کادر را بکشد: ۵ تا ۹۵ درصد.
ys.push(cy[Math.floor(cy.length * 0.05)], cy[Math.ceil(cy.length * 0.95) - 1]);
}
if (!xs.length) return null;
if (spec.axes) { xs.push(0); ys.push(0); }
let [x0, x1] = [Math.min(...xs), Math.max(...xs)];
let [y0, y1] = [Math.min(...ys), Math.max(...ys)];
const span = Math.max(x1 - x0, y1 - y0, 1e-6);
const margin = span * 0.14 + 0.2;
x0 -= margin; x1 += margin; y0 -= margin; y1 += margin;
const scale = (W - 2 * PAD) / (x1 - x0);
const height = Math.min(Math.max((y1 - y0) * scale + 2 * PAD, 150), 340);
// اگر ارتفاع سقف خورد، مقیاس را از روی ارتفاع بگیر تا دایره بیضی نشود.
const s = Math.min(scale, (height - 2 * PAD) / (y1 - y0));
const ox = (W - (x1 - x0) * s) / 2, oy = (height - (y1 - y0) * s) / 2;
const X = (x: number) => ox + (x - x0) * s;
const Y = (y: number) => oy + (y1 - y) * s;
const P = ([x, y]: XY): XY => [X(x), Y(y)];
const els: ReactNode[] = [];
let key = 0;
const k = () => key++;
if (spec.axes) {
const ticks = niceTicks(x0, x1), yticks = niceTicks(y0, y1);
els.push(
<g key={k()} stroke="#d9d7ea" strokeWidth={1}>
{ticks.map((t) => <line key={`gx${t}`} x1={X(t)} x2={X(t)} y1={Y(y0)} y2={Y(y1)} strokeDasharray="2 4" />)}
{yticks.map((t) => <line key={`gy${t}`} y1={Y(t)} y2={Y(t)} x1={X(x0)} x2={X(x1)} strokeDasharray="2 4" />)}
</g>,
<g key={k()} stroke={SOFT} strokeWidth={1.4} markerEnd="url(#dg-arrow-soft)">
<line x1={X(x0)} y1={Y(0)} x2={X(x1)} y2={Y(0)} />
<line x1={X(0)} y1={Y(y0)} x2={X(0)} y2={Y(y1)} />
</g>,
<g key={k()} fill={SOFT} fontSize={9} textAnchor="middle">
{ticks.filter((t) => t !== 0).map((t) => <text key={`tx${t}`} x={X(t)} y={Y(0) + 11}>{fa(t)}</text>)}
{yticks.filter((t) => t !== 0).map((t) => <text key={`ty${t}`} x={X(0) - 8} y={Y(t) + 3}>{fa(t)}</text>)}
</g>,
);
}
const centroid: XY = (() => {
const all = [...named.values()];
if (!all.length) return [0, 0];
return [all.reduce((a, p) => a + p[0], 0) / all.length, all.reduce((a, p) => a + p[1], 0) / all.length];
})();
for (const item of spec.items) {
if (item.type === 'circle') {
const c = ref(item.center);
if (!c) continue;
const [cx, cy] = P(c);
els.push(<circle key={k()} cx={cx} cy={cy} r={item.r * s} fill="none" stroke={MAIN} strokeWidth={2.2} />);
} else if (item.type === 'segment' || item.type === 'arrow') {
const a = ref(item.from), b = ref(item.to);
if (!a || !b) continue;
const [ax, ay] = P(a), [bx, by] = P(b);
els.push(
<line key={k()} x1={ax} y1={ay} x2={bx} y2={by} stroke={item.type === 'arrow' ? ACCENT : INK} strokeWidth={2.2}
strokeLinecap="round" markerEnd={item.type === 'arrow' ? 'url(#dg-arrow)' : undefined} />,
);
if (item.label) els.push(sideLabel(k(), [ax, ay], [bx, by], P(centroid), item.label));
} else if (item.type === 'line') {
const a = ref(item.through[0]), b = ref(item.through[1]);
if (!a || !b || (a[0] === b[0] && a[1] === b[1])) continue;
const [dx, dy] = [b[0] - a[0], b[1] - a[1]];
const t = (x1 - x0 + y1 - y0) * 2 / Math.hypot(dx, dy);
const [px, py] = P([a[0] - dx * t, a[1] - dy * t]), [qx, qy] = P([a[0] + dx * t, a[1] + dy * t]);
els.push(<line key={k()} x1={px} y1={py} x2={qx} y2={qy} stroke={MAIN} strokeWidth={1.8} />);
} else if (item.type === 'polygon') {
const pts = item.points.map(ref).filter((p): p is XY => p !== null).map(P);
if (pts.length < 3) continue;
els.push(<polygon key={k()} points={pts.map((p) => p.join(',')).join(' ')} fill="#4C5FD514" stroke={INK} strokeWidth={2.2} strokeLinejoin="round" />);
} else if (item.type === 'angle' || item.type === 'rightAngle') {
const v = ref(item.vertex), a = ref(item.from), b = ref(item.to);
if (!v || !a || !b) continue;
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.min(24, Math.hypot(ax - vx, ay - vy) * 0.4, Math.hypot(bx - vx, by - vy) * 0.4);
if (item.type === 'rightAngle') {
const ua: XY = [Math.cos(ta), Math.sin(ta)], ub: XY = [Math.cos(tb), Math.sin(tb)];
const q = Math.min(r, 12);
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];
els.push(<polyline key={k()} points={[p1, p2, p3].map((p) => p.join(',')).join(' ')} fill="none" stroke={ACCENT} strokeWidth={1.8} />);
} else {
const end: XY = [vx + r * Math.cos(ta + d), vy + r * Math.sin(ta + d)];
const start: XY = [vx + r * Math.cos(ta), vy + r * Math.sin(ta)];
els.push(
<path key={k()} d={`M${vx},${vy} L${start[0]},${start[1]} A${r},${r} 0 0 ${d > 0 ? 1 : 0} ${end[0]},${end[1]} Z`}
fill="#ED695626" stroke={ACCENT} strokeWidth={1.8} />,
);
}
if (item.label) {
const mid = ta + d / 2, lr = r + 13;
els.push(<Label key={k()} x={vx + lr * Math.cos(mid)} y={vy + lr * Math.sin(mid) + 4} color={ACCENT}>{item.label}</Label>);
}
} else if (item.type === 'text') {
const [tx, ty] = P(item.at);
els.push(<Label key={k()} x={tx} y={ty} color={INK}>{item.text}</Label>);
}
}
for (const { item, pts } of curves) {
const clipped = pts.filter(([, y]) => y >= y0 - (y1 - y0) && y <= y1 + (y1 - y0));
const d = clipped.map((p, i) => `${i ? 'L' : 'M'}${X(p[0]).toFixed(1)},${Y(p[1]).toFixed(1)}`).join(' ');
els.push(<path key={k()} d={d} fill="none" stroke={MAIN} strokeWidth={2.4} strokeLinejoin="round" />);
if (item.label) {
const last = clipped[Math.floor(clipped.length * 0.85)] ?? clipped[clipped.length - 1];
els.push(<Label key={k()} x={X(last[0])} y={Y(last[1]) - 8} color={MAIN}>{item.label}</Label>);
}
}
// نقطه‌ها آخر از همه، روی بقیه
const pointLabels = new Map<string, string>();
const extraPoints: { xy: XY; label: string }[] = [];
for (const item of spec.items) {
if (item.type !== 'point') continue;
if (typeof item.point === 'string') pointLabels.set(item.point, item.label ?? item.point);
else extraPoints.push({ xy: item.point, label: item.label ?? '' });
}
const drawPoint = (xy: XY, label: string) => {
const [px, py] = P(xy);
const [cx, cy] = P(centroid);
let [dx, dy] = [px - cx, py - cy];
const n = Math.hypot(dx, dy) || 1;
[dx, dy] = [(dx / n) * 13, (dy / n) * 13 || -13];
els.push(<circle key={k()} cx={px} cy={py} r={3.6} fill={INK} />);
if (label) els.push(<Label key={k()} x={px + dx} y={py + dy + 4} color={INK} bold>{label}</Label>);
};
named.forEach((xy, name) => {
if (name.startsWith('_')) return;
const isOriginOnAxes = spec.axes && xy[0] === 0 && xy[1] === 0 && !pointLabels.has(name);
drawPoint(xy, isOriginOnAxes ? '' : pointLabels.get(name) ?? name);
});
extraPoints.forEach((p) => drawPoint(p.xy, p.label));
return (
<svg viewBox={`0 0 ${W} ${height}`} className="mx-auto block w-full max-w-[320px]" role="img" aria-label={spec.title || 'شکل'}>
<Markers />
{els}
</svg>
);
}
/* ─── محورِ اعداد ─────────────────────────────────────────────────────────── */
function NumberLine({ spec }: { spec: Extract<DiagramSpec, { kind: 'numberline' }> }) {
const { min, max } = spec;
const height = spec.jumps.length ? 120 : 80;
const base = height - 38;
const X = (v: number) => PAD + ((v - min) / (max - min)) * (W - 2 * PAD);
const step = niceStep(max - min, 12);
const ticks: number[] = [];
for (let t = Math.ceil(min / step) * step; t <= max + 1e-9; t += step) ticks.push(Math.round(t * 1e6) / 1e6);
const labelled = new Set(spec.points.map((p) => p.at));
return (
<svg viewBox={`0 0 ${W} ${height}`} className="mx-auto block w-full max-w-[340px]" role="img" aria-label={spec.title || 'محورِ اعداد'}>
<Markers />
<line x1={PAD - 12} y1={base} x2={W - PAD + 12} y2={base} stroke={INK} strokeWidth={2} markerEnd="url(#dg-arrow-ink)" markerStart="url(#dg-arrow-ink-start)" />
{ticks.map((t) => (
<g key={t}>
<line x1={X(t)} x2={X(t)} y1={base - 5} y2={base + 5} stroke={INK} strokeWidth={t === 0 ? 2 : 1.3} />
<text x={X(t)} y={base + 19} fontSize={labelled.has(t) ? 11 : 10} fontWeight={labelled.has(t) ? 800 : 400} fill={labelled.has(t) ? INK : SOFT} textAnchor="middle">
{fa(t)}
</text>
</g>
))}
{spec.jumps.map((j, i) => {
const a = X(j.from), b = X(j.to);
const lift = Math.min(46, Math.abs(b - a) * 0.5 + 14);
return (
<g key={`j${i}`}>
<path d={`M${a},${base - 6} Q${(a + b) / 2},${base - 6 - lift * 1.4} ${b},${base - 6}`} fill="none" stroke={ACCENT} strokeWidth={2.2} markerEnd="url(#dg-arrow)" />
{j.label && <Label x={(a + b) / 2} y={base - 10 - lift * 0.75} color={ACCENT}>{j.label}</Label>}
</g>
);
})}
{spec.points.map((p, i) => (
<g key={`p${i}`}>
<circle cx={X(p.at)} cy={base} r={5.5} fill={i === spec.points.length - 1 && spec.points.length > 1 ? ACCENT : MAIN} stroke="#fff" strokeWidth={1.5} />
{p.label && !/^[-−]?[\d۰-۹.]+$/.test(p.label) && <Label x={X(p.at)} y={base + 33} color={INK}>{p.label}</Label>}
</g>
))}
</svg>
);
}
/* ─── کسر ─────────────────────────────────────────────────────────────────── */
function Fraction({ spec }: { spec: Extract<DiagramSpec, { kind: 'fraction' }> }) {
const { parts, shaded } = spec;
const fill = (i: number) => (i < shaded ? '#FFC63D' : '#fff');
if (spec.shape === 'rect') {
const w = (W - 2 * PAD) / parts;
return (
<svg viewBox={`0 0 ${W} 90`} className="mx-auto block w-full max-w-[320px]" role="img" aria-label={spec.title || 'کسر'}>
{Array.from({ length: parts }, (_, i) => (
<rect key={i} x={W - PAD - (i + 1) * w} y={18} width={w} height={46} fill={fill(i)} stroke={INK} strokeWidth={2} />
))}
<text x={W / 2} y={84} fontSize={12} fontWeight={800} fill={INK} textAnchor="middle">{toFa(shaded)} از {toFa(parts)}</text>
</svg>
);
}
const cx = W / 2, cy = 82, r = 64;
return (
<svg viewBox={`0 0 ${W} 170`} className="mx-auto block w-full max-w-[260px]" role="img" aria-label={spec.title || 'کسر'}>
{parts === 1 ? (
<circle cx={cx} cy={cy} r={r} fill={fill(0)} stroke={INK} strokeWidth={2} />
) : (
Array.from({ length: parts }, (_, i) => {
const a0 = -Math.PI / 2 + (2 * Math.PI * i) / parts, a1 = -Math.PI / 2 + (2 * Math.PI * (i + 1)) / parts;
const large = a1 - a0 > Math.PI ? 1 : 0;
return (
<path key={i} d={`M${cx},${cy} L${cx + r * Math.cos(a0)},${cy + r * Math.sin(a0)} A${r},${r} 0 ${large} 1 ${cx + r * Math.cos(a1)},${cy + r * Math.sin(a1)} Z`}
fill={fill(i)} stroke={INK} strokeWidth={2} strokeLinejoin="round" />
);
})
)}
<text x={cx} y={164} fontSize={12} fontWeight={800} fill={INK} textAnchor="middle">{toFa(shaded)} از {toFa(parts)} تکه</text>
</svg>
);
}
/* ─── کمکی‌ها ─────────────────────────────────────────────────────────────── */
function Markers() {
const head = (id: string, color: string, start = false) => (
<marker id={id} viewBox="0 0 10 10" refX={start ? 2 : 8} refY="5" markerWidth="7" markerHeight="7" orient={start ? 'auto-start-reverse' : 'auto'}>
<path d="M0,0 L10,5 L0,10 z" fill={color} />
</marker>
);
return (
<defs>
{head('dg-arrow', ACCENT)}
{head('dg-arrow-soft', SOFT)}
{head('dg-arrow-ink', INK)}
{head('dg-arrow-ink-start', INK, true)}
</defs>
);
}
function Label({ x, y, color, bold = false, children }: { x: number; y: number; color: string; bold?: boolean; children: ReactNode }) {
return (
<text x={x} y={y} fontSize={11} fontWeight={bold ? 800 : 700} fill={color} textAnchor="middle"
stroke="#fbfbff" strokeWidth={3} paintOrder="stroke" style={{ unicodeBidi: 'plaintext' }}>
{children}
</text>
);
}
/** برچسبِ ضلع، وسطِ پاره‌خط و به سمتِ بیرونِ شکل. */
function sideLabel(key: number, a: XY, b: XY, center: XY, text: string) {
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; }
return <Label key={key} x={mx + nx * 13} y={my + ny * 13 + 4} color={MAIN}>{text}</Label>;
}
function niceStep(span: number, maxTicks: number): number {
const raw = span / maxTicks;
const pow = 10 ** Math.floor(Math.log10(raw || 1));
const m = raw / pow;
return (m <= 1 ? 1 : m <= 2 ? 2 : m <= 5 ? 5 : 10) * pow;
}
function niceTicks(lo: number, hi: number): number[] {
const step = niceStep(hi - lo, 8);
const out: number[] = [];
for (let t = Math.ceil(lo / step) * step; t <= hi; t += step) out.push(Math.round(t * 1e6) / 1e6);
return out;
}
const fa = (n: number) => toFa(String(n).replace('-', '−'));

View File

@@ -0,0 +1,41 @@
import { Diagram } from '@/diagram';
import type { DiagramSpec } from '@/diagram/types';
import { SAMPLES } from './diagramSamples';
import { MathText } from '@/lib/MathText';
const TEXTS = [
'۵) پس وقتی از ۲ سه واحد به چپ برویم، به عدد -۱ می‌رسیم.',
'✅ جواب: رأس سهمی در (1, -1) است.',
'۴) ۲ - ۳ = -۱.',
'مجموع زاویه‌های داخلی = (5 - 2) × 180 = 3 × 180 = 540 درجه',
'بازه‌ی [-2, 3) یعنی از −۲ تا ۳، خودِ ۳ نه.',
'sin 30° = 0.5 و cos 30° ≈ 0.87 است.',
'در کتاب ۲/۵ یعنی دو و نیم (اختیاری).',
];
/**
* گالریِ شکل‌ها، فقط در حالتِ توسعه: http://localhost:5173/?gallery
* برای وارسیِ چشمی بعد از هر تغییر در src/diagram. اگر window.__GALLERY__ ست شده
* باشد (تستِ خودکار با خروجیِ واقعیِ مدل)، همان‌ها را نشان می‌دهد.
*/
export function DiagramGallery() {
const injected = (window as unknown as { __GALLERY__?: { title: string; spec: DiagramSpec }[] }).__GALLERY__;
const items = injected ?? SAMPLES;
return (
<div dir="rtl" className="min-h-screen bg-white p-3">
{!injected && (
<div className="mx-auto mb-3 max-w-[420px] space-y-1.5 rounded-2xl border border-line p-3 text-sm leading-7" data-texts>
{TEXTS.map((t) => <p key={t} className="rounded-xl bg-soft px-3 py-1.5"><MathText text={t} /></p>)}
</div>
)}
<div className="mx-auto grid max-w-[1200px] grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{items.map((it, i) => (
<section key={i} data-sample={i} className="rounded-2xl border border-line p-2">
<h2 className="mb-1 text-xs font-black text-body">{i + 1}. {it.title}</h2>
<Diagram spec={it.spec} />
</section>
))}
</div>
</div>
);
}

226
src/dev/diagramSamples.ts Normal file
View File

@@ -0,0 +1,226 @@
import type { DiagramSpec } from '@/diagram/types';
/** نمونه‌های دست‌نویس از هر نوع شکل، از روی مسئله‌های واقعیِ کتاب‌ها. فقط برای گالری. */
export const SAMPLES: { title: string; spec: DiagramSpec }[] = [
{
title: 'یازدهم — سهمی با ریشه، رأس، محورِ تقارن و عرض از مبدأ',
spec: { kind: 'plane', title: 'y = x² − 2x − 3', axes: true, points: {}, items: [
{ type: 'function', expr: 'x^2-2x-3', from: -2.5, to: 4.5, label: 'y = x² − 2x − 3', marks: ['roots', 'vertex', 'axis', 'yIntercept'] },
] },
},
{
title: 'دهم — دایره‌ی مثلثاتی، سینوس و کسینوسِ ۳۰°',
spec: { kind: 'plane', title: 'زاویه‌ی ۳۰ درجه', axes: true, points: { O: [0, 0], A: [1, 0], P: { polar: [1, 30] }, H: { foot: ['P', 'O', 'A'] } }, items: [
{ type: 'circle', center: 'O', r: 1 },
{ type: 'segment', from: 'O', to: 'P' },
{ type: 'segment', from: 'P', to: 'H', dashed: true, color: 'green', label: 'sin 30° = 0.5' },
{ type: 'segment', from: 'O', to: 'H', color: 'orange', label: 'cos 30°' },
{ type: 'angle', vertex: 'O', from: 'A', to: 'P', label: '30°' },
{ type: 'rightAngle', vertex: 'H', from: 'O', to: 'P' },
] },
},
{
title: 'نهم — مثلثِ قائم‌الزاویه، فیثاغورس',
spec: { kind: 'plane', title: 'وتر = ۵', axes: false, points: { A: [0, 0], B: [4, 0], C: [0, 3] }, items: [
{ type: 'polygon', points: ['A', 'B', 'C'] },
{ type: 'rightAngle', vertex: 'A', from: 'B', to: 'C' },
{ type: 'segment', from: 'A', to: 'B', label: '4' },
{ type: 'segment', from: 'A', to: 'C', label: '3' },
{ type: 'segment', from: 'B', to: 'C', label: '?', color: 'red' },
] },
},
{
title: 'هشتم — خطوطِ موازی و مورب، زاویه‌های مساوی',
spec: { kind: 'plane', title: 'a ∥ b و d مورب', axes: false, points: { _a1: [0, 2], _a2: [6, 2], _b1: [0, 0], _b2: [6, 0], _d1: [1.2, -1], _d2: [4.8, 3], P: { intersect: [['_a1', '_a2'], ['_d1', '_d2']] }, Q: { intersect: [['_b1', '_b2'], ['_d1', '_d2']] } }, items: [
{ type: 'line', through: ['_a1', '_a2'], label: 'a' },
{ type: 'line', through: ['_b1', '_b2'], label: 'b' },
{ type: 'line', through: ['_d1', '_d2'], color: 'purple', label: 'd' },
{ type: 'angle', vertex: 'P', from: '_a2', to: '_d2', label: 'Â1', arcs: 1 },
{ type: 'angle', vertex: 'Q', from: '_b2', to: '_d2', label: 'B̂1', arcs: 1 },
] },
},
{
title: 'هشتم — زاویه‌ی محاطی و مرکزیِ روبه‌روی یک کمان',
spec: { kind: 'plane', title: 'زاویه‌ی محاطی نصفِ مرکزی است', axes: false, points: { O: [0, 0], A: { polar: [2, 200] }, B: { polar: [2, -20] }, C: { polar: [2, 100] } }, items: [
{ type: 'circle', center: 'O', r: 2 },
{ type: 'arc', center: 'O', r: 2, start: 200, end: 340, label: 'AB' },
{ type: 'segment', from: 'C', to: 'A' }, { type: 'segment', from: 'C', to: 'B' },
{ type: 'segment', from: 'O', to: 'A', color: 'blue' }, { type: 'segment', from: 'O', to: 'B', color: 'blue' },
{ type: 'angle', vertex: 'C', from: 'A', to: 'B', label: '70°' },
{ type: 'angle', vertex: 'O', from: 'A', to: 'B', label: '140°', color: 'blue' },
] },
},
{
title: 'نهم — متوازی‌الاضلاع و وسطِ ضلع‌ها، ضلع‌های مساوی',
spec: { kind: 'plane', title: 'M و N وسطِ ضلع‌ها', axes: false, points: { A: [0, 0], B: [4, 0], C: [5, 2.5], D: [1, 2.5], M: { mid: ['A', 'B'] }, N: { mid: ['C', 'D'] } }, items: [
{ type: 'polygon', points: ['A', 'B', 'C', 'D'] },
{ type: 'segment', from: 'A', to: 'M', ticks: 1 }, { type: 'segment', from: 'M', to: 'B', ticks: 1 },
{ type: 'segment', from: 'D', to: 'N', ticks: 2 }, { type: 'segment', from: 'N', to: 'C', ticks: 2 },
{ type: 'segment', from: 'M', to: 'N', dashed: true, color: 'purple' },
] },
},
{
title: 'هفتم — دورانِ ۹۰° مثلث حولِ C در جهتِ عقربه‌ها، روی شبکه',
spec: { kind: 'plane', title: 'دورانِ ۹۰°', axes: false, grid: true, points: { A: [1, 1], B: [4, 1], C: [1, 3] }, items: [
{ type: 'polygon', points: ['A', 'B', 'C'] },
{ type: 'image', of: ['A', 'B', 'C'], transform: { rotate: { center: 'C', deg: -90 } } },
] },
},
{
title: 'هشتم — بردار و جمعِ بردارها روی شبکه',
spec: { kind: 'plane', title: 'AB + BC = AC', axes: false, grid: true, points: { A: [0, 0], B: [3, 1], C: [4, 4] }, items: [
{ type: 'vector', from: 'A', to: 'B', label: 'a' },
{ type: 'vector', from: 'B', to: 'C', label: 'b', color: 'blue' },
{ type: 'vector', from: 'A', to: 'C', label: 'a + b', color: 'green' },
] },
},
{
title: 'نهم — دو خط و محلِ برخورد',
spec: { kind: 'plane', title: 'y = 2x − 1 و y = −x + 5', axes: true, points: { P: { intersect: [[[0, -1], [1, 1]], [[0, 5], [1, 4]]] } }, items: [
{ type: 'function', expr: '2x-1', from: -1, to: 4, label: 'y = 2x − 1' },
{ type: 'function', expr: '-x+5', from: -1, to: 6, label: 'y = −x + 5' },
{ type: 'point', point: 'P', label: 'P (2, 3)' },
] },
},
{
title: 'یازدهم — نمودارِ سینوس با مضرب‌های π',
spec: { kind: 'plane', title: 'y = sin x', axes: true, trig: true, points: {}, items: [
{ type: 'function', expr: 'sin(x)', from: -6.2832, to: 6.2832, label: 'y = sin x', marks: ['roots', 'extrema'] },
{ type: 'hline', y: 0.5, color: 'orange', label: 'y = ½' },
] },
},
{
title: 'یازدهم — تابعِ چندضابطه‌ای و حد (نقطه‌ی توخالی)',
spec: { kind: 'plane', title: 'حدِ چپ و راست در x = 1', axes: true, points: {}, items: [
{ type: 'piecewise', pieces: [{ expr: 'x+1', from: -2, to: 1, closedFrom: true, closedTo: false }, { expr: '-x+4', from: 1, to: 4, closedFrom: true, closedTo: true }] },
{ type: 'vline', x: 1, label: 'x = 1' },
] },
},
{
title: 'دوازدهم — خطِ مماس بر نمودار',
spec: { kind: 'plane', title: 'مماس بر y = x² در x = 1', axes: true, points: {}, items: [
{ type: 'function', expr: 'x^2', from: -2, to: 2.5, label: 'y = x²' },
{ type: 'tangent', expr: 'x^2', at: 1, label: 'y = 2x − 1' },
] },
},
{
title: 'دوازدهم — مجانب‌ها: y = 1/(x − 2) + 1',
spec: { kind: 'plane', title: 'مجانبِ قائم و افقی', axes: true, xRange: [-2, 6], yRange: [-4, 6], points: {}, items: [
{ type: 'function', expr: '1/(x-2)+1', from: -2, to: 6, label: 'y = 1/(x−2) + 1' },
{ type: 'vline', x: 2, label: 'x = 2', color: 'red' },
{ type: 'hline', y: 1, label: 'y = 1', color: 'red' },
] },
},
{
title: 'دوازدهم — اکسترمم‌های نسبیِ تابعِ درجه‌ی سوم',
spec: { kind: 'plane', title: 'y = x³ − 3x', axes: true, points: {}, items: [
{ type: 'function', expr: 'x^3-3x', from: -2.3, to: 2.3, label: 'y = x³ − 3x', marks: ['extrema', 'roots'] },
] },
},
{
title: 'یازدهم — تابع و وارونش، قرینه نسبت به y = x',
spec: { kind: 'plane', title: 'f و f⁻¹', axes: true, xRange: [-1, 8], yRange: [-1, 8], points: {}, items: [
{ type: 'function', expr: '2^x', from: -1, to: 3, label: 'y = 2ˣ' },
{ type: 'function', expr: 'log(x)/log(2)', from: 0.3, to: 8, label: 'y = log₂x' },
{ type: 'function', expr: 'x', from: -1, to: 8, dashed: true, color: 'gray', label: 'y = x' },
] },
},
{
title: 'دوازدهم — مساحتِ زیرِ نمودار',
spec: { kind: 'plane', title: 'ناحیه‌ی بینِ y = √x و محورِ x', axes: true, points: {}, items: [
{ type: 'area', expr: 'sqrt(x)', from: 0, to: 4, color: 'green', label: 'S' },
{ type: 'function', expr: 'sqrt(x)', from: 0, to: 5, label: 'y = √x', color: 'green' },
] },
},
{
title: 'دهم — ناحیه‌ی جوابِ نامعادله',
spec: { kind: 'plane', title: 'y > 2x − 1', axes: true, xRange: [-3, 3], yRange: [-4, 4], points: {}, items: [
{ type: 'region', expr: '2x-1', side: 'above', strict: true, label: 'y > 2x − 1' },
] },
},
{
title: 'هشتم — محورِ اعداد: از ۲ سه واحد به چپ',
spec: { kind: 'numberline', title: '۲ − ۳ = −۱', min: -4, max: 4, points: [{ at: 2, label: 'شروع' }, { at: -1, label: 'پایان' }], jumps: [{ from: 2, to: -1, label: '۳ واحد به چپ' }] },
},
{
title: 'دهم — بازه‌ها روی محور',
spec: { kind: 'numberline', title: '[−۲، ۳) و (۱، +∞)', min: -4, max: 5, points: [], jumps: [], intervals: [
{ from: -2, to: 3, closedFrom: true, closedTo: false, label: '[−2, 3)' },
{ from: 1, to: null, closedFrom: false, label: '(1, +∞)' },
] },
},
{
title: 'ششم — کسرها روی محور',
spec: { kind: 'numberline', title: 'سه‌چهارم', min: 0, max: 2, step: 0.25, fractions: true, points: [{ at: 0.75, label: 'سه‌چهارم' }], jumps: [] },
},
{
title: 'ششم — کسر روی دایره',
spec: { kind: 'fraction', title: 'سه‌هشتم', shape: 'circle', parts: 8, shaded: 3 },
},
{
title: 'ششم — ۲۰٪ از ۲۵ خانه',
spec: { kind: 'grid', title: '۲۰ درصد', rows: 5, cols: 5, shaded: 5 },
},
{
title: 'سوم — مدلِ ضرب: ۳ گروهِ ۴تایی',
spec: { kind: 'groups', title: '۳ × ۴', groups: 3, each: 4, item: 'circle' },
},
{
title: 'سوم — ساعت ۳ و ۲۵ دقیقه',
spec: { kind: 'clock', title: 'ساعت ۳:۲۵', hour: 3, minute: 25 },
},
{
title: 'سوم — نمودارِ ستونیِ آب‌میوه‌ها',
spec: { kind: 'bar', title: 'آب‌میوه‌ی موردِ علاقه', labels: ['پرتقال', 'سیب', 'انگور', 'هلو'], values: [8, 5, 3, 4], unit: 'نفر' },
},
{
title: 'هشتم — نمودارِ دایره‌ای',
spec: { kind: 'pie', title: 'ورزشِ موردِ علاقه', labels: ['فوتبال', 'والیبال', 'شنا', 'بقیه'], values: [12, 6, 4, 2] },
},
{
title: 'دهم — دمای زمین بر حسبِ عمق (خط‌شکسته)',
spec: { kind: 'linechart', title: 'دما (°C)', labels: ['1', '2', '3', '4', '5', '6'], values: [55, 90, 125, 160, 195, 230], unit: '°C' },
},
{
title: 'دهم — جدولِ تعیینِ علامت',
spec: { kind: 'table', title: 'y = (x + 1)(x − 2)', headers: ['x', '−∞', '−1', '', '2', '+∞'], rows: [['y', '+', '0', '−', '0', '+']] },
},
{
title: 'دهم — نمودارِ ون: A ∩ B',
spec: { kind: 'venn', title: 'A = {1,2,3,4} و B = {3,4,5}', sets: ['A', 'B'], regions: { A: ['1', '2'], AB: ['3', '4'], B: ['5'], out: ['6'] }, shade: ['AB'] },
},
{
title: 'دهم — ون با سه مجموعه',
spec: { kind: 'venn', title: 'A − (B ∪ C)', sets: ['A', 'B', 'C'], regions: { A: ['1'], B: ['2'], C: ['3'], AB: ['4'], ABC: ['5'] }, shade: ['A'] },
},
{
title: 'دهم — نمودارِ پیکانیِ تابع',
spec: { kind: 'mapping', title: 'f(x) = 3(x − 1)²', left: ['0', '0.5', '1'], right: ['3', '0.75', '0'], arrows: [[0, 0], [1, 1], [2, 2]], leftLabel: 'دامنه', rightLabel: 'برد' },
},
{
title: 'نهم — نمودارِ درختیِ دو بار پرتابِ سکه',
spec: { kind: 'tree', title: '۴ حالت', root: { label: 'شروع', children: [
{ label: 'رو', p: '1/2', children: [{ label: 'رو', p: '1/2' }, { label: 'پشت', p: '1/2' }] },
{ label: 'پشت', p: '1/2', children: [{ label: 'رو', p: '1/2' }, { label: 'پشت', p: '1/2' }] },
] } },
},
{ title: 'هفتم — مکعب‌مستطیل', spec: { kind: 'solid', title: 'حجم = ۵ × ۳ × ۴', shape: 'cuboid', dims: { a: 5, b: 3, c: 4 }, labels: { a: '5 cm', b: '3 cm', c: '4 cm' } } },
{ title: 'هفتم — مکعب', spec: { kind: 'solid', title: 'مکعب', shape: 'cube', dims: { a: 3 }, labels: { a: 'a' } } },
{ title: 'هفتم — منشورِ سه‌پهلو', spec: { kind: 'solid', title: 'منشورِ مثلثی', shape: 'prism', dims: { n: 3, r: 1.2, h: 2 }, labels: { h: 'h' } } },
{ title: 'نهم — منشورِ شش‌پهلو', spec: { kind: 'solid', title: 'منشورِ شش‌ضلعی', shape: 'prism', dims: { n: 6, r: 1.2, h: 1.6 }, labels: { h: '8 cm' } } },
{ title: 'نهم — هرمِ مربع‌القاعده با ارتفاع', spec: { kind: 'solid', title: 'هرم', shape: 'pyramid', dims: { n: 4, r: 1.3, h: 2 }, labels: { h: 'h', a: 'a' } } },
{ title: 'نهم — هرمِ پنج‌پهلو', spec: { kind: 'solid', title: 'قاعده‌ی ABCDE', shape: 'pyramid', dims: { n: 5, r: 1.3, h: 2 }, labels: { h: 'OH' } } },
{ title: 'نهم — استوانه', spec: { kind: 'solid', title: 'لیوانِ استوانه‌ای', shape: 'cylinder', dims: { r: 6, h: 10 }, labels: { r: '6 cm', h: 'h' } } },
{ title: 'نهم — مخروط', spec: { kind: 'solid', title: 'ظرفِ مخروطی', shape: 'cone', dims: { r: 4, h: 12 }, labels: { r: '4 cm', h: '12 cm' } } },
{ title: 'نهم — کره', spec: { kind: 'solid', title: 'کره', shape: 'sphere', dims: { r: 1 }, labels: { r: 'r' } } },
{ title: 'نهم — نیم‌کره', spec: { kind: 'solid', title: 'پیمانه‌ی نیم‌کره', shape: 'hemisphere', dims: { r: 12 }, labels: { r: '12 cm' } } },
{ title: 'ششم — مقایسه‌ی یک‌پنجم و نُه‌دهم', spec: { kind: 'fraction', title: 'کدام بزرگ‌تر است؟', shape: 'rect', parts: 5, shaded: 1, compare: [{ parts: 5, shaded: 1, label: 'یک‌پنجم' }, { parts: 10, shaded: 9, label: 'نُه‌دهم' }] } },
{ title: 'ششم — دو و هفت‌دهم', spec: { kind: 'fraction', title: '۲٫۷', shape: 'circle', parts: 10, shaded: 7, wholes: 2 } },
{ title: 'دهم — دایره و خطِ مماس از معادله', spec: { kind: 'plane', title: 'فاصله‌ی مرکز تا خط', axes: true, points: { O: [-1, 2] }, items: [
{ type: 'line', eq: [3, 5, 4], label: '3x + 5y = 4' }, { type: 'circle', center: 'O', r: 0.5145, color: 'green' },
] } },
{ title: 'هشتم — زاویه‌ی خارجیِ پنج‌ضلعی با امتدادِ ضلع', spec: { kind: 'plane', title: 'زاویه‌ی خارجی = ۷۲°', axes: false, points: { A: { polar: [2, 234] }, B: { polar: [2, 306] }, C: { polar: [2, 18] }, D: { polar: [2, 90] }, E: { polar: [2, 162] }, X: { along: ['A', 'B', 1.6] } }, items: [
{ type: 'polygon', points: ['A', 'B', 'C', 'D', 'E'] }, { type: 'segment', from: 'B', to: 'X', dashed: true }, { type: 'angle', vertex: 'B', from: 'X', to: 'C', label: '72°' },
] } },
{ title: 'سوم — ضرب روی محورِ اعداد (تیک‌های زیاد)', spec: { kind: 'numberline', title: '۲ × ۱۱', min: 0, max: 25, points: [{ at: 11, label: '' }, { at: 22, label: '' }], jumps: [{ from: 0, to: 11, label: '۱۱' }, { from: 11, to: 22, label: '۱۱' }] } },
];

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>
);
}

View File

@@ -19,7 +19,7 @@ const FUNCS: Record<string, (v: number) => number> = {
}; };
function tokenize(src: string): string[] { function tokenize(src: string): string[] {
const s = src.replace(/\s+/g, '').replace(/[−–]/g, '-').replace(/[×·]/g, '*').replace(/÷/g, '/').replace(/²/g, '^2').replace(/³/g, '^3').replace(/π/g, 'pi'); const s = src.replace(/\s+/g, '').replace(/\*\*/g, '^').replace(/[−–]/g, '-').replace(/[×·]/g, '*').replace(/÷/g, '/').replace(/²/g, '^2').replace(/³/g, '^3').replace(/π/g, 'pi');
const out: string[] = []; const out: string[] = [];
let i = 0; let i = 0;
while (i < s.length) { while (i < s.length) {
@@ -108,3 +108,72 @@ export function parseExpr(src: string): (x: number) => number {
}; };
return (x) => ev(tree, x); return (x) => ev(tree, x);
} }
/* ─── تحلیلِ عددی: ریشه، اکسترمم، شیب ───────────────────────────────────────
* همه با دو برابر کردنِ دقت (bisection) تا ۱e-10؛ برای نمایش به دو رقمِ اعشار گرد
* می‌شوند. ریشه‌ی مضاعف (مثلِ x² در صفر) علامت عوض نمی‌کند، پس جدا از روی
* اکسترممی که مقدارش صفر است پیدا می‌شود.
*/
type F = (x: number) => number;
function bisect(g: F, a: number, b: number): number {
let ga = g(a);
for (let i = 0; i < 80; i++) {
const m = (a + b) / 2, gm = g(m);
if (!Number.isFinite(gm)) return m;
if (Math.sign(gm) === Math.sign(ga)) { a = m; ga = gm; } else b = m;
if (b - a < 1e-11) break;
}
return (a + b) / 2;
}
/** مشتقِ عددی (تفاضلِ مرکزی). */
export function derivative(f: F, x: number): number {
const h = 1e-5 * Math.max(1, Math.abs(x));
return (f(x + h) - f(x - h)) / (2 * h);
}
const snap = (x: number) => {
const r = Math.round(x);
if (Math.abs(x - r) < 1e-7) return r;
const r2 = Math.round(x * 1e6) / 1e6;
return Math.abs(x - r2) < 1e-9 ? r2 : x;
};
export function extrema(f: F, from: number, to: number): { x: number; y: number; kind: 'max' | 'min' }[] {
const n = 600, out: { x: number; y: number; kind: 'max' | 'min' }[] = [];
const d = (x: number) => derivative(f, x);
let px = from, pd = d(px);
for (let i = 1; i <= n; i++) {
const x = from + ((to - from) * i) / n, dx = d(x);
if (Number.isFinite(pd) && Number.isFinite(dx) && pd !== 0 && Math.sign(dx) !== Math.sign(pd)) {
const xe = snap(bisect(d, px, x));
const y = f(xe);
// لبه‌ی مجانب (1/x) علامتِ مشتق را بی‌اکسترمم عوض نمی‌کند: تابع باید پیوسته باشد.
if (Number.isFinite(y) && Math.abs(f(px) - f(x)) < 1e3 * (Math.abs(y) + 1)) out.push({ x: xe, y: snap(y), kind: pd > 0 ? 'max' : 'min' });
}
px = x; pd = dx;
}
return out;
}
export function roots(f: F, from: number, to: number): number[] {
const n = 800, out: number[] = [];
let px = from, py = f(px);
if (Math.abs(py) < 1e-12) out.push(from);
for (let i = 1; i <= n; i++) {
const x = from + ((to - from) * i) / n, y = f(x);
if (Math.abs(y) < 1e-12) out.push(x);
else if (Number.isFinite(py) && Number.isFinite(y) && py !== 0 && Math.sign(y) !== Math.sign(py)) {
const r = bisect(f, px, x);
// پرشِ علامت بدونِ گذر از صفر (مجانب) ریشه نیست.
if (Math.abs(f(r)) < 1e-6) out.push(r);
}
px = x; py = y;
}
for (const e of extrema(f, from, to)) if (Math.abs(e.y) < 1e-9) out.push(e.x);
const uniq: number[] = [];
for (const r of out.map(snap).sort((a, b) => a - b)) if (!uniq.length || Math.abs(r - uniq[uniq.length - 1]) > 1e-6) uniq.push(r);
return uniq;
}

58
src/diagram/index.tsx Normal file
View File

@@ -0,0 +1,58 @@
import { Component, memo, type ReactNode } from 'react';
import type { DiagramSpec } from './types';
import { Plane } from './plane';
import { NumberLine } from './numberline';
import { Solid } from './solid';
import { Chart } from './charts';
import { Mapping, Tree, Venn } from './sets';
import { Clock, Fraction, Grid, Groups, Table } from './misc';
import { PAPER } from './svg';
import { MathText } from '@/lib/MathText';
export type { DiagramSpec } from './types';
/**
* شکلِ جوابِ دستیار. توصیف از بک‌اند می‌آید (rag/services/diagram.py، اعتبارسنجی‌شده)
* و اینجا فقط به عنصرِ React/SVG تبدیل می‌شود؛ هیچ HTMLی از مدل وارد صفحه نمی‌شود.
*
* هر خطایی در رسم (شکلی که کد پیش‌بینی نکرده) فقط همین شکل را پنهان می‌کند؛
* متنِ جواب سرِ جایش می‌ماند.
*/
function Body({ spec }: { spec: DiagramSpec }): ReactNode {
switch (spec.kind) {
case 'plane': return <Plane spec={spec} />;
case 'numberline': return <NumberLine spec={spec} />;
case 'solid': return <Solid spec={spec} />;
case 'bar': case 'linechart': case 'pie': return <Chart spec={spec} />;
case 'venn': return <Venn spec={spec} />;
case 'mapping': return <Mapping spec={spec} />;
case 'tree': return <Tree spec={spec} />;
case 'fraction': return <Fraction spec={spec} />;
case 'grid': return <Grid spec={spec} />;
case 'groups': return <Groups spec={spec} />;
case 'clock': return <Clock spec={spec} />;
case 'table': return <Table spec={spec} />;
default: return null;
}
}
class Guard extends Component<{ children: ReactNode }, { broken: boolean }> {
state = { broken: false };
static getDerivedStateFromError() { return { broken: true }; }
render() { return this.state.broken ? null : this.props.children; }
}
function DiagramBase({ spec }: { spec: DiagramSpec }) {
return (
<Guard>
{/* dir=ltr: محور و مختصات چپ‌به‌راست‌اند؛ در زمینه‌ی rtl «−۱» به «۱−» برمی‌گشت.
برچسب‌های فارسی با unicode-bidi: plaintext جهتِ خودشان را می‌گیرند. */}
<figure dir="ltr" className="mt-2 overflow-hidden rounded-2xl border border-line p-2" style={{ backgroundColor: PAPER }}>
<Body spec={spec} />
{spec.title && <figcaption dir="rtl" className="mt-1 text-center text-[11px] font-bold text-muted"><MathText text={spec.title} /></figcaption>}
</figure>
</Guard>
);
}
export const Diagram = memo(DiagramBase);

219
src/diagram/misc.tsx Normal file
View File

@@ -0,0 +1,219 @@
import type { ClockSpec, FractionSpec, GridSpec, GroupsSpec, TableSpec } from './types';
import { COLORS, INK, bidi, color, mathText } from './svg';
import { toFa } from '@/lib/format';
const W = 320;
const SHADE = COLORS.yellow;
/* ─── کسر: دایره یا نوارِ تکه‌تکه ─────────────────────────────────────────── */
export function Fraction({ spec }: { spec: FractionSpec }) {
if (spec.compare?.length) return <FractionCompare items={spec.compare} />;
if (spec.wholes) return <MixedNumber wholes={spec.wholes} parts={spec.parts} shaded={spec.shaded} shape={spec.shape} />;
const { parts, shaded } = spec;
const fill = (i: number) => (i < shaded ? SHADE : '#fff');
const caption = <text x={W / 2} y={spec.shape === 'rect' ? 88 : 166} fontSize={12} fontWeight={800} fill={INK} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>{`${toFa(shaded)} تکه از ${toFa(parts)} تکه`}</text>;
if (spec.shape === 'rect') {
const w = (W - 40) / parts;
return (
<svg viewBox={`0 0 ${W} 96`} className="mx-auto block w-full max-w-[320px]" role="img">
{/* از راست به چپ رنگ می‌شود، مثلِ خواندنِ فارسی */}
{Array.from({ length: parts }, (_, i) => <rect key={i} x={W - 20 - (i + 1) * w} y={18} width={w} height={48} fill={fill(i)} stroke={INK} strokeWidth={2} />)}
{caption}
</svg>
);
}
const cx = W / 2, cy = 80, r = 66;
return (
<svg viewBox={`0 0 ${W} 176`} className="mx-auto block w-full max-w-[260px]" role="img">
{parts === 1 ? <circle cx={cx} cy={cy} r={r} fill={fill(0)} stroke={INK} strokeWidth={2} /> : Array.from({ length: parts }, (_, i) => {
const a0 = -Math.PI / 2 + (2 * Math.PI * i) / parts, a1 = -Math.PI / 2 + (2 * Math.PI * (i + 1)) / parts;
return <path key={i} d={`M${cx},${cy} L${cx + r * Math.cos(a0)},${cy + r * Math.sin(a0)} A${r},${r} 0 ${a1 - a0 > Math.PI ? 1 : 0} 1 ${cx + r * Math.cos(a1)},${cy + r * Math.sin(a1)} Z`} fill={fill(i)} stroke={INK} strokeWidth={2} strokeLinejoin="round" />;
})}
{caption}
</svg>
);
}
/** چند کسر روی نوارهای هم‌اندازه، برای مقایسه (یک‌پنجم و نُه‌دهم). */
function FractionCompare({ items }: { items: { parts: number; shaded: number; label?: string }[] }) {
const barW = W - 110, h = 30, gap = 16;
const H = items.length * (h + gap) + 8;
const cols = [COLORS.yellow, COLORS.teal, COLORS.orange, COLORS.purple];
return (
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[340px]" role="img">
{items.map((f, r) => {
const y = 8 + r * (h + gap), w = barW / f.parts;
return (
<g key={r}>
{Array.from({ length: f.parts }, (_, i) => (
<rect key={i} x={W - 12 - (i + 1) * w} y={y} width={w} height={h} fill={i < f.shaded ? cols[r % cols.length] : '#fff'} stroke={INK} strokeWidth={1.6} />
))}
<text x={W - 20 - barW} y={y + h / 2 + 4} fontSize={11.5} fontWeight={800} fill={INK} textAnchor="end" style={{ unicodeBidi: 'plaintext' }}>
{f.label ?? `${toFa(f.shaded)} از ${toFa(f.parts)}`}
</text>
</g>
);
})}
</svg>
);
}
/** عددِ مخلوط: چند واحدِ کامل و یک کسر (دو و هفت‌دهم). */
function MixedNumber({ wholes, parts, shaded, shape }: { wholes: number; parts: number; shaded: number; shape: 'circle' | 'rect' }) {
const n = wholes + 1, r = Math.min(40, (W - 20) / n / 2 - 6), cy = 52;
const cx = (i: number) => W - 10 - r - 6 - i * (2 * r + 12);
const slice = (x: number, i: number, full: boolean) => {
const a0 = -Math.PI / 2 + (2 * Math.PI * i) / parts, a1 = -Math.PI / 2 + (2 * Math.PI * (i + 1)) / parts;
return <path key={i} d={`M${x},${cy} L${x + r * Math.cos(a0)},${cy + r * Math.sin(a0)} A${r},${r} 0 ${a1 - a0 > Math.PI ? 1 : 0} 1 ${x + r * Math.cos(a1)},${cy + r * Math.sin(a1)} Z`} fill={full || i < shaded ? SHADE : '#fff'} stroke={INK} strokeWidth={1.6} />;
};
return (
<svg viewBox={`0 0 ${W} 120`} className="mx-auto block w-full max-w-[340px]" role="img">
{Array.from({ length: wholes }, (_, w) => (shape === 'rect'
? <rect key={w} x={cx(w) - r} y={cy - r} width={2 * r} height={2 * r} fill={SHADE} stroke={INK} strokeWidth={2} />
: <circle key={w} cx={cx(w)} cy={cy} r={r} fill={SHADE} stroke={INK} strokeWidth={2} />))}
<g>{parts === 1 ? <circle cx={cx(wholes)} cy={cy} r={r} fill={shaded ? SHADE : '#fff'} stroke={INK} strokeWidth={2} /> : Array.from({ length: parts }, (_, i) => slice(cx(wholes), i, false))}</g>
<text x={W / 2} y={112} fontSize={12} fontWeight={800} fill={INK} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>
{`${toFa(wholes)} واحدِ کامل و ${toFa(shaded)} تکه از ${toFa(parts)}`}
</text>
</svg>
);
}
/* ─── شبکه‌ی خانه‌ها: درصد، مساحت با شمردن، کسر روی جدول ─────────────────── */
export function Grid({ spec }: { spec: GridSpec }) {
const rows = spec.rows, cols = spec.cols;
const cell = Math.min(26, (W - 30) / cols, 220 / rows);
const gw = cols * cell, gh = rows * cell;
const x0 = (W - gw) / 2, y0 = 12;
const on = new Set<string>();
if (typeof spec.shaded === 'number') {
// سطر به سطر، از بالا و از راست (جهتِ خواندنِ فارسی)
for (let i = 0; i < Math.min(spec.shaded, rows * cols); i++) on.add(`${Math.floor(i / cols)},${cols - 1 - (i % cols)}`);
} else spec.shaded.forEach(([r, c]) => on.add(`${r},${c}`));
const col = color(spec.color, SHADE);
const count = on.size;
return (
<svg viewBox={`0 0 ${W} ${gh + 44}`} className="mx-auto block w-full max-w-[320px]" role="img">
{Array.from({ length: rows }, (_, r) => Array.from({ length: cols }, (_, c) => (
<rect key={`${r}-${c}`} x={x0 + c * cell} y={y0 + r * cell} width={cell} height={cell} fill={on.has(`${r},${c}`) ? col : '#fff'} stroke="#B8BCCD" strokeWidth={1} />
)))}
<rect x={x0} y={y0} width={gw} height={gh} fill="none" stroke={INK} strokeWidth={2} />
<text x={W / 2} y={gh + 34} fontSize={12} fontWeight={800} fill={INK} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>
{`${toFa(count)} خانه از ${toFa(rows * cols)} خانه${100 % (rows * cols) === 0 || (rows * cols) % 10 === 0 ? `، یعنی ${toFa(Math.round((count / (rows * cols)) * 1000) / 10)}٪` : ''}`}
</text>
</svg>
);
}
/* ─── گروه‌ها: مدلِ ضرب و تقسیم برای دبستان ───────────────────────────────── */
export function Groups({ spec }: { spec: GroupsSpec }) {
const g = spec.groups, each = spec.each;
const perRow = Math.min(g, g <= 4 ? g : Math.ceil(g / 2));
const rowsG = Math.ceil(g / perRow);
const inner = Math.ceil(Math.sqrt(each));
const dot = Math.min(14, 64 / inner);
const boxW = inner * dot + 16, boxH = Math.ceil(each / inner) * dot + 16;
const gap = 12;
const totalW = perRow * boxW + (perRow - 1) * gap;
const scale = Math.min(1, (W - 20) / totalW);
const H = (rowsG * (boxH + gap) + 30) * scale + 10;
const shape = spec.item ?? 'circle';
const cols = [COLORS.red, COLORS.blue, COLORS.green, COLORS.orange, COLORS.purple];
return (
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[320px]" role="img">
<g transform={`translate(${(W - totalW * scale) / 2} 8) scale(${scale})`}>
{Array.from({ length: g }, (_, gi) => {
const bx = (gi % perRow) * (boxW + gap), by = Math.floor(gi / perRow) * (boxH + gap);
const c = cols[gi % cols.length];
return (
<g key={gi} transform={`translate(${bx} ${by})`}>
<rect width={boxW} height={boxH} rx={10} fill={`${c}12`} stroke={c} strokeWidth={2} strokeDasharray="5 3" />
{Array.from({ length: each }, (_, i) => {
const x = 8 + (i % inner) * dot + dot / 2, y = 8 + Math.floor(i / inner) * dot + dot / 2, s = dot * 0.36;
if (shape === 'square') return <rect key={i} x={x - s} y={y - s} width={s * 2} height={s * 2} rx={2} fill={c} />;
if (shape === 'star') return <path key={i} d={star(x, y, s * 1.2)} fill={c} />;
return <circle key={i} cx={x} cy={y} r={s} fill={c} />;
})}
</g>
);
})}
</g>
<text x={W / 2} y={H - 8} fontSize={12} fontWeight={800} fill={INK} textAnchor="middle" {...bidi(mathText(spec.label ?? `${g} × ${each} = ${g * each}`))}>
{mathText(spec.label ?? `${g} × ${each} = ${g * each}`)}
</text>
</svg>
);
}
function star(cx: number, cy: number, r: number): string {
const pts = Array.from({ length: 10 }, (_, i) => {
const t = -Math.PI / 2 + (i * Math.PI) / 5, rr = i % 2 ? r * 0.45 : r;
return `${cx + rr * Math.cos(t)},${cy + rr * Math.sin(t)}`;
});
return `M${pts.join(' L')} Z`;
}
/* ─── ساعت ────────────────────────────────────────────────────────────────── */
export function Clock({ spec }: { spec: ClockSpec }) {
const cx = W / 2, cy = 100, r = 84;
const h = ((spec.hour % 12) + spec.minute / 60) * 30, m = spec.minute * 6;
const hand = (deg: number, len: number, w: number, c: string) => {
const t = ((deg - 90) * Math.PI) / 180;
return <line x1={cx} y1={cy} x2={cx + len * Math.cos(t)} y2={cy + len * Math.sin(t)} stroke={c} strokeWidth={w} strokeLinecap="round" />;
};
return (
<svg viewBox={`0 0 ${W} 200`} className="mx-auto block w-full max-w-[240px]" role="img">
<circle cx={cx} cy={cy} r={r} fill="#fff" stroke={INK} strokeWidth={3} />
{Array.from({ length: 60 }, (_, i) => {
const t = (i * 6 - 90) * (Math.PI / 180), big = i % 5 === 0;
return <line key={i} x1={cx + (r - (big ? 10 : 5)) * Math.cos(t)} y1={cy + (r - (big ? 10 : 5)) * Math.sin(t)} x2={cx + (r - 2) * Math.cos(t)} y2={cy + (r - 2) * Math.sin(t)} stroke={INK} strokeWidth={big ? 2.2 : 1} />;
})}
{Array.from({ length: 12 }, (_, i) => {
const n = i + 1, t = (n * 30 - 90) * (Math.PI / 180);
return <text key={n} x={cx + (r - 22) * Math.cos(t)} y={cy + (r - 22) * Math.sin(t) + 5} fontSize={14} fontWeight={800} fill={INK} textAnchor="middle">{toFa(n)}</text>;
})}
{hand(h, r * 0.5, 5, COLORS.blue)}
{hand(m, r * 0.78, 3, COLORS.red)}
<circle cx={cx} cy={cy} r={4.5} fill={INK} />
</svg>
);
}
/* ─── جدول (مقدارها، تعیینِ علامت) ───────────────────────────────────────── */
export function Table({ spec }: { spec: TableSpec }) {
const cellStyle = (v: string) => {
const t = v.trim();
if (t === '+') return { color: COLORS.green, fontWeight: 900 };
if (t === '-' || t === '−') return { color: COLORS.red, fontWeight: 900 };
if (t === '0' || t === '۰') return { color: INK, fontWeight: 900 };
return undefined;
};
return (
// جدولِ ریاضی (تعیینِ علامت، مقدارها) چپ‌به‌راست است، مثلِ محورِ x.
<div className="overflow-x-auto" dir="ltr">
<table className="mx-auto border-collapse text-center text-[12px]">
{spec.headers.length > 0 && (
<thead>
<tr>{spec.headers.map((h, i) => <th key={i} className="border border-[#d8dae6] bg-[#eef0fb] px-2.5 py-1.5 font-black text-ink" style={bidi(mathText(h)).style}>{mathText(h)}</th>)}</tr>
</thead>
)}
<tbody>
{spec.rows.map((row, r) => (
<tr key={r}>
{row.map((c, i) => (
<td key={i} className={`border border-[#d8dae6] px-2.5 py-1.5 ${i === 0 ? 'bg-[#f6f7fc] font-bold' : 'bg-white'}`} style={{ ...cellStyle(c), ...bidi(mathText(c)).style }}>
{mathText(c)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}

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>
);
}

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];
}

166
src/diagram/sets.tsx Normal file
View File

@@ -0,0 +1,166 @@
import { useId } from 'react';
import type { MappingSpec, TreeNode, TreeSpec, VennSpec } from './types';
import { Arrowheads, COLORS, INK, Label, SERIES, arrowId, mathText } from './svg';
const W = 320;
/* ─── نمودارِ ون ──────────────────────────────────────────────────────────
* ناحیه‌ها با نامِ مجموعه‌هایی که در آن‌اند: «A» فقط A، «AB» اشتراکِ A و B (و نه
* C)، «ABC»، و «out» بیرونِ همه. سایه با برشِ تو در تو (clipPath) و ماسکِ
* «نه-این-مجموعه» ساخته می‌شود، پس ناحیه‌ی سایه‌خورده دقیقاً همان ناحیه است. */
export function Venn({ spec }: { spec: VennSpec }) {
const uid = useId().replace(/:/g, '');
const sets = spec.sets.slice(0, 3);
if (sets.length < 1) return null;
const H = sets.length === 3 ? 250 : 190;
const R = sets.length === 3 ? 62 : 70;
const centers: [number, number][] = sets.length === 1 ? [[W / 2, 95]]
: sets.length === 2 ? [[W / 2 - 45, 95], [W / 2 + 45, 95]]
: [[W / 2 - 40, 95], [W / 2 + 40, 95], [W / 2, 160]];
const ids = sets.map((_, i) => `v${uid}-${i}`);
const keyOf = (inside: number[]) => inside.map((i) => sets[i]).join('');
// مرکزِ تقریبیِ هر ناحیه برای نوشتنِ اعضا
const regionCenter = (code: string): [number, number] => {
if (code === 'out') return [22, H - 16];
const inside = sets.map((s, i) => (code.includes(s) ? i : -1)).filter((i) => i >= 0);
const all = centers.map((_, i) => i);
const cxIn = inside.reduce((a, i) => a + centers[i][0], 0) / inside.length, cyIn = inside.reduce((a, i) => a + centers[i][1], 0) / inside.length;
const outside = all.filter((i) => !inside.includes(i));
if (!outside.length || inside.length > 1) {
if (inside.length === sets.length) return [cxIn, cyIn];
const o = outside.length ? centers[outside[0]] : [cxIn, cyIn];
return [cxIn + (cxIn - o[0]) * 0.18, cyIn + (cyIn - o[1]) * 0.18];
}
const ox = outside.reduce((a, i) => a + centers[i][0], 0) / outside.length, oy = outside.reduce((a, i) => a + centers[i][1], 0) / outside.length;
const dx = cxIn - ox, dy = cyIn - oy, n = Math.hypot(dx, dy) || 1;
return [cxIn + (dx / n) * R * 0.42, cyIn + (dy / n) * R * 0.42];
};
const shaded = (spec.shade ?? []).filter((c) => c === 'out' || [...c].every((ch) => sets.includes(ch)));
const shadeRegion = (code: string, idx: number) => {
const fill = `${COLORS.orange}66`;
if (code === 'out') {
return (
<g key={`s${idx}`}>
<mask id={`${ids[0]}-out`}><rect x={0} y={0} width={W} height={H} fill="#fff" />{centers.map((c, i) => <circle key={i} cx={c[0]} cy={c[1]} r={R} fill="#000" />)}</mask>
<rect x={4} y={4} width={W - 8} height={H - 8} fill={fill} mask={`url(#${ids[0]}-out)`} />
</g>
);
}
const inside = sets.map((s, i) => (code.includes(s) ? i : -1)).filter((i) => i >= 0);
const outside = sets.map((_, i) => i).filter((i) => !inside.includes(i));
const mid = `${ids[0]}-m${idx}`;
let node = (
<g>
{outside.length > 0 && <mask id={mid}><rect x={0} y={0} width={W} height={H} fill="#fff" />{outside.map((i) => <circle key={i} cx={centers[i][0]} cy={centers[i][1]} r={R} fill="#000" />)}</mask>}
<rect x={0} y={0} width={W} height={H} fill={fill} mask={outside.length ? `url(#${mid})` : undefined} />
</g>
);
for (const i of inside) node = <g clipPath={`url(#${ids[i]})`}>{node}</g>;
return <g key={`s${idx}`}>{node}</g>;
};
return (
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[340px]" role="img">
<defs>{centers.map((c, i) => <clipPath key={i} id={ids[i]}><circle cx={c[0]} cy={c[1]} r={R} /></clipPath>)}</defs>
<rect x={4} y={4} width={W - 8} height={H - 8} rx={10} fill="none" stroke={INK} strokeWidth={1.5} />
<text x={W - 12} y={20} fontSize={12} fontWeight={800} fill={INK} textAnchor="end" fontStyle="italic">U</text>
{shaded.map(shadeRegion)}
{centers.map((c, i) => {
const col = COLORS[SERIES[i]];
return (
<g key={i}>
<circle cx={c[0]} cy={c[1]} r={R} fill={`${col}14`} stroke={col} strokeWidth={2.4} />
<Label x={c[0] + (i === 1 ? R * 0.72 : i === 0 ? -R * 0.72 : 0)} y={i === 2 ? c[1] + R + 14 : c[1] - R * 0.78} color={col} size={spec.names?.[i] ? 11.5 : 13} weight={900}>{spec.names?.[i] ?? sets[i]}</Label>
</g>
);
})}
{Object.entries(spec.regions).map(([code, members]) => {
const valid = code === 'out' || [...code].every((ch) => sets.includes(ch));
if (!valid || !members.length) return null;
const [x, y] = regionCenter(code === 'out' ? 'out' : keyOf(sets.map((s, i) => (code.includes(s) ? i : -1)).filter((i) => i >= 0)));
const lines = chunk(members.slice(0, 12), 3);
return lines.map((line, li) => (
<Label key={`${code}${li}`} x={x} y={y + (li - (lines.length - 1) / 2) * 14 + 4} color={INK} size={11} anchor={code === 'out' ? 'start' : 'middle'}>
{mathText(line.join('، '))}
</Label>
));
})}
</svg>
);
}
const chunk = <T,>(xs: T[], n: number): T[][] => Array.from({ length: Math.ceil(xs.length / n) }, (_, i) => xs.slice(i * n, i * n + n));
/* ─── نمودارِ پیکانی (تابع از A به B) ─────────────────────────────────────── */
export function Mapping({ spec }: { spec: MappingSpec }) {
const L = spec.left.slice(0, 10), Rt = spec.right.slice(0, 10);
const rows = Math.max(L.length, Rt.length, 1);
const H = 56 + rows * 28;
const yOf = (i: number, n: number) => 40 + ((i + 0.5) * (rows * 28)) / n;
// جهتِ نگاشت چپ به راست است، مثلِ کتاب (دامنه سمتِ چپ)
const lx = 78, rx = W - 78;
const col = COLORS.red;
return (
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[340px]" role="img">
<Arrowheads ids={[col]} />
{[[lx, L, spec.leftLabel, COLORS.blue], [rx, Rt, spec.rightLabel, COLORS.green]].map(([x, items, label, c]) => (
<g key={String(x)}>
<ellipse cx={x as number} cy={40 + (rows * 28) / 2} rx={42} ry={(rows * 28) / 2 + 14} fill={`${c}14`} stroke={c as string} strokeWidth={2.2} />
{label && <Label x={x as number} y={18} color={c as string} size={12} weight={900}>{label as string}</Label>}
{(items as string[]).map((it, i) => (
<Label key={i} x={x as number} y={yOf(i, (items as string[]).length) + 4} color={INK} size={12}>{mathText(it)}</Label>
))}
</g>
))}
{spec.arrows.filter(([a, b]) => a < L.length && b < Rt.length).map(([a, b], i) => (
<line key={i} x1={lx + 18} y1={yOf(a, L.length)} x2={rx - 18} y2={yOf(b, Rt.length)} stroke={col} strokeWidth={1.8} markerEnd={`url(#${arrowId(col)})`} />
))}
</svg>
);
}
/* ─── نمودارِ درختی (احتمال و شمارش) ──────────────────────────────────────── */
export function Tree({ spec }: { spec: TreeSpec }) {
interface Laid { node: TreeNode; x: number; y: number; depth: number; parent?: Laid }
const laid: Laid[] = [];
let leaf = 0;
const depthOf = (n: TreeNode): number => 1 + Math.max(0, ...(n.children ?? []).map(depthOf));
const depth = Math.min(4, depthOf(spec.root));
const levelH = 62;
const place = (n: TreeNode, d: number, parent?: Laid): Laid => {
const kids = d < 3 ? (n.children ?? []).slice(0, 6) : [];
const me: Laid = { node: n, x: 0, y: 24 + d * levelH, depth: d, parent };
if (!kids.length) { me.x = leaf++; laid.push(me); return me; }
const placed = kids.map((c) => place(c, d + 1, me));
me.x = (placed[0].x + placed[placed.length - 1].x) / 2;
laid.push(me);
return me;
};
place(spec.root, 0);
const leaves = Math.max(1, leaf);
const H = 24 + (depth - 1) * levelH + 34;
const X = (x: number) => 24 + ((x + 0.5) / leaves) * (W - 48);
return (
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[340px]" role="img">
{laid.filter((n) => n.parent).map((n, i) => {
const p = n.parent!;
return (
<g key={`e${i}`}>
<line x1={X(p.x)} y1={p.y + 9} x2={X(n.x)} y2={n.y - 11} stroke={COLORS.gray} strokeWidth={1.8} />
{n.node.p && <Label x={(X(p.x) + X(n.x)) / 2 + (n.x < p.x ? -10 : n.x > p.x ? 10 : 12)} y={(p.y + n.y) / 2 + 3} color={COLORS.red} size={10}>{mathText(n.node.p)}</Label>}
</g>
);
})}
{laid.map((n, i) => (
<g key={`n${i}`}>
<rect x={X(n.x) - 26} y={n.y - 11} width={52} height={21} rx={10} fill={n.depth === 0 ? `${COLORS.blue}22` : '#fff'} stroke={COLORS.blue} strokeWidth={1.6} />
<text x={X(n.x)} y={n.y + 4} fontSize={10.5} fontWeight={800} fill={INK} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>{mathText(n.node.label).slice(0, 10)}</text>
</g>
))}
</svg>
);
}

156
src/diagram/solid.tsx Normal file
View File

@@ -0,0 +1,156 @@
import type { ReactNode } from 'react';
import type { SolidSpec } from './types';
import { COLORS, INK, Label, mathText } from './svg';
/**
* حجم‌های هندسی، با تصویرِ مایلِ کتابِ درسی: وجهِ جلو واقعی، عمق با زاویه‌ی ۳۵°
* و نصفِ اندازه؛ یال‌های پنهان خط‌چین. اندازه‌ها (a، b، c، r، h) نسبتِ واقعی را
* نگه می‌دارند؛ برچسب‌ها همان متنی‌اند که مدل داده («۵ cm»).
*/
type XY = [number, number];
const W = 300;
const H = 250;
const FILL = `${COLORS.blue}1c`;
const TOP = `${COLORS.blue}33`;
const SIDE = `${COLORS.blue}10`;
const HID = '5 4';
const DEPTH = 0.5;
const ANG = (35 * Math.PI) / 180;
const SQUASH = 0.32; // دایره‌ی قاعده از نمای مایل
export function Solid({ spec }: { spec: SolidSpec }) {
const d = spec.dims ?? {};
const l = spec.labels ?? {};
const els: ReactNode[] = [];
let key = 0;
const k = () => key++;
const edge = (a: XY, b: XY, hidden = false, col = INK) =>
els.push(<line key={k()} x1={a[0]} y1={a[1]} x2={b[0]} y2={b[1]} stroke={hidden ? '#8A8FA3' : col} strokeWidth={hidden ? 1.5 : 2.2} strokeDasharray={hidden ? HID : undefined} strokeLinecap="round" />);
const face = (pts: XY[], fill: string) => els.push(<path key={k()} d={`M${pts.map((p) => p.join(',')).join(' L')} Z`} fill={fill} stroke="none" />);
const label = (x: number, y: number, text: string | undefined, col = COLORS.red) => {
if (text) els.push(<Label key={k()} x={x} y={y} color={col} size={12}>{mathText(text)}</Label>);
};
const dim = (a: XY, b: XY, text: string | undefined) => {
// خطِ اندازه: خط‌چینِ قرمز با برچسب وسطش
els.push(<line key={k()} x1={a[0]} y1={a[1]} x2={b[0]} y2={b[1]} stroke={COLORS.red} strokeWidth={1.8} strokeDasharray="4 3" />);
if (text) label((a[0] + b[0]) / 2 + 12, (a[1] + b[1]) / 2 + 4, text);
};
const shape = spec.shape;
if (shape === 'cube' || shape === 'cuboid') {
let a = d.a ?? 1, b = d.b ?? (shape === 'cube' ? a : 1), c = d.c ?? (shape === 'cube' ? a : 1);
if (shape === 'cube') b = c = a;
const ox = Math.cos(ANG) * DEPTH * b, oy = Math.sin(ANG) * DEPTH * b;
const s = Math.min(170 / (a + ox), 170 / (c + oy));
const x0 = (W - (a + ox) * s) / 2, y0 = H - 30;
const f = (x: number, y: number, z: number): XY => [x0 + (x + z * Math.cos(ANG) * DEPTH) * s, y0 - (y + z * Math.sin(ANG) * DEPTH) * s];
const [A, B, C, D] = [f(0, 0, 0), f(a, 0, 0), f(a, c, 0), f(0, c, 0)];
const [E, F, G, Hh] = [f(0, 0, b), f(a, 0, b), f(a, c, b), f(0, c, b)];
face([D, C, G, Hh], TOP); face([B, F, G, C], SIDE); face([A, B, C, D], FILL);
edge(A, E, true); edge(E, F, true); edge(E, Hh, true);
[[A, B], [B, C], [C, D], [D, A], [B, F], [F, G], [G, C], [G, Hh], [Hh, D]].forEach(([p, q]) => edge(p, q));
label((A[0] + B[0]) / 2, A[1] + 17, l.a);
label(A[0] - 18, (A[1] + D[1]) / 2 + 4, l.c ?? (shape === 'cube' ? undefined : undefined));
label((B[0] + F[0]) / 2 + 16, (B[1] + F[1]) / 2 + 10, l.b);
} else if (shape === 'prism' || shape === 'pyramid') {
const n = Math.max(3, Math.min(8, Math.round(d.n ?? (shape === 'pyramid' ? 4 : 3))));
const r = d.r ?? 1, h = d.h ?? 1.6;
const s = Math.min(95 / r, 165 / h);
const cx = W / 2, by = H - 44;
const rot = n === 4 ? Math.PI / 4 : Math.PI / 2 + Math.PI / n;
const ang = Array.from({ length: n }, (_, i) => rot + (2 * Math.PI * i) / n);
// sin(t) > 0 یعنی پایینِ بیضی در تصویر = جلوی حجم، نزدیک به بیننده.
const base: XY[] = ang.map((t) => [cx + r * s * Math.cos(t), by + r * s * SQUASH * Math.sin(t)]);
const midAng = (i: number) => ang[i] + Math.PI / n;
const inr = r * Math.cos(Math.PI / n);
// دیده شدنِ وجهِ جانبیِ i (بینِ رأسِ i و i+1): عمودِ بیرونی رو به بیننده.
// نما کمی از بالاست (tan ≈ SQUASH)، پس وجهِ مایلِ هرم کمی زودتر دیده می‌شود.
const faceVisible = (i: number) => shape === 'prism'
? Math.sin(midAng(i)) > 1e-9
: h * Math.sin(midAng(i)) + inr * SQUASH > 1e-9;
const vertexVisible = (i: number) => faceVisible(i) || faceVisible((i - 1 + n) % n);
if (shape === 'prism') {
const top: XY[] = base.map((p) => [p[0], p[1] - h * s]);
face(top, TOP);
for (let i = 0; i < n; i++) if (faceVisible(i)) face([base[i], base[(i + 1) % n], top[(i + 1) % n], top[i]], FILL);
for (let i = 0; i < n; i++) {
const j = (i + 1) % n;
edge(base[i], base[j], !faceVisible(i));
edge(top[i], top[j]);
edge(base[i], top[i], !vertexVisible(i));
}
const right = base.reduce((m, p, i) => (p[0] > base[m][0] ? i : m), 0);
if (l.h) dim([top[right][0] + 16, top[right][1]], [base[right][0] + 16, base[right][1]], l.h);
} else {
const apex: XY = [cx, by - h * s];
for (let i = 0; i < n; i++) if (faceVisible(i)) face([base[i], base[(i + 1) % n], apex], i % 2 ? FILL : SIDE);
for (let i = 0; i < n; i++) {
edge(base[i], base[(i + 1) % n], !faceVisible(i));
edge(base[i], apex, !vertexVisible(i));
}
if (l.h) {
edge(apex, [cx, by], true, COLORS.red);
els.push(<path key={k()} d={`M${cx + 8},${by} L${cx + 8},${by - 8} L${cx},${by - 8}`} fill="none" stroke={COLORS.red} strokeWidth={1.4} />);
label(cx + 16, (apex[1] + by) / 2, l.h);
}
}
// ضلعِ قاعده: پایین‌ترین یالِ جلو
if (l.a) {
let best = 0;
for (let i = 0; i < n; i++) if (base[i][1] + base[(i + 1) % n][1] > base[best][1] + base[(best + 1) % n][1]) best = i;
label((base[best][0] + base[(best + 1) % n][0]) / 2, (base[best][1] + base[(best + 1) % n][1]) / 2 + 17, l.a);
}
} else if (shape === 'cylinder' || shape === 'cone') {
const r = d.r ?? 1, h = d.h ?? 2;
const s = Math.min(110 / r, 170 / (h + r * SQUASH * 2));
const cx = W / 2, by = H - 36, rx = r * s, ry = r * s * SQUASH;
const topY = by - h * s;
const halfEllipse = (cy: number, front: boolean) => `M${cx - rx},${cy} A${rx},${ry} 0 0 ${front ? 0 : 1} ${cx + rx},${cy}`;
if (shape === 'cylinder') {
els.push(<path key={k()} d={`M${cx - rx},${topY} L${cx - rx},${by} A${rx},${ry} 0 0 0 ${cx + rx},${by} L${cx + rx},${topY} Z`} fill={FILL} />);
els.push(<ellipse key={k()} cx={cx} cy={topY} rx={rx} ry={ry} fill={TOP} stroke={INK} strokeWidth={2.2} />);
edge([cx - rx, topY], [cx - rx, by]); edge([cx + rx, topY], [cx + rx, by]);
els.push(<path key={k()} d={halfEllipse(by, true)} fill="none" stroke={INK} strokeWidth={2.2} />);
els.push(<path key={k()} d={halfEllipse(by, false)} fill="none" stroke="#8A8FA3" strokeWidth={1.5} strokeDasharray={HID} />);
if (l.r) { dim([cx, topY], [cx + rx, topY], undefined); els.push(<circle key={k()} cx={cx} cy={topY} r={2.6} fill={INK} />); label(cx + rx / 2, topY - 8, l.r); }
if (l.h) dim([cx + rx + 16, topY], [cx + rx + 16, by], l.h);
} else {
const apex: XY = [cx, topY];
// مماس از رأس بر بیضیِ قاعده: نقطه‌ی تماس جایی است که sin t = −ry / ارتفاعِ تصویر
// (از صفر شدنِ ضربِ خارجیِ «رأس→نقطه» در «مشتقِ بیضی» درمی‌آید).
const st = ry / Math.max(1e-6, by - topY), ct = Math.sqrt(Math.max(0, 1 - st * st));
const tl: XY = [cx - rx * ct, by - ry * st], tr: XY = [cx + rx * ct, by - ry * st];
els.push(<path key={k()} d={`M${apex.join(',')} L${tl.join(',')} A${rx},${ry} 0 1 0 ${tr.join(',')} Z`} fill={FILL} />);
els.push(<path key={k()} d={`M${tl.join(',')} A${rx},${ry} 0 1 0 ${tr.join(',')}`} fill="none" stroke={INK} strokeWidth={2.2} />);
els.push(<path key={k()} d={`M${tl.join(',')} A${rx},${ry} 0 0 1 ${tr.join(',')}`} fill="none" stroke="#8A8FA3" strokeWidth={1.5} strokeDasharray={HID} />);
edge(apex, tl); edge(apex, tr);
if (l.h) {
edge(apex, [cx, by], true, COLORS.red);
els.push(<path key={k()} d={`M${cx + 8},${by} L${cx + 8},${by - 8} L${cx},${by - 8}`} fill="none" stroke={COLORS.red} strokeWidth={1.4} />);
label(cx + 22, (topY + by) / 2 - 10, l.h);
}
if (l.r) { dim([cx, by], [cx + rx, by], undefined); label(cx + rx / 2, by + ry + 16, l.r); }
}
} else if (shape === 'sphere' || shape === 'hemisphere') {
const r = d.r ?? 1;
const R = 92, cx = W / 2, cy = shape === 'sphere' ? H / 2 + 4 : H - 70, ry = R * SQUASH;
if (shape === 'sphere') {
els.push(<circle key={k()} cx={cx} cy={cy} r={R} fill={FILL} stroke={INK} strokeWidth={2.2} />);
els.push(<path key={k()} d={`M${cx - R},${cy} A${R},${ry} 0 0 0 ${cx + R},${cy}`} fill="none" stroke={INK} strokeWidth={1.6} />);
els.push(<path key={k()} d={`M${cx - R},${cy} A${R},${ry} 0 0 1 ${cx + R},${cy}`} fill="none" stroke="#8A8FA3" strokeWidth={1.4} strokeDasharray={HID} />);
} else {
els.push(<path key={k()} d={`M${cx - R},${cy} A${R},${R} 0 0 1 ${cx + R},${cy} A${R},${ry} 0 0 1 ${cx - R},${cy} Z`} fill={FILL} />);
els.push(<path key={k()} d={`M${cx - R},${cy} A${R},${R} 0 0 1 ${cx + R},${cy}`} fill="none" stroke={INK} strokeWidth={2.2} />);
els.push(<ellipse key={k()} cx={cx} cy={cy} rx={R} ry={ry} fill={TOP} stroke={INK} strokeWidth={2} />);
}
els.push(<circle key={k()} cx={cx} cy={cy} r={2.8} fill={INK} />);
if (l.r) { dim([cx, cy], [cx + R, cy], undefined); label(cx + R / 2, cy - 8, l.r); }
void r;
} else return null;
return (
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[300px]" role="img" aria-label={spec.title || 'حجم'}>
{els}
</svg>
);
}

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)}`;
}

111
src/diagram/types.ts Normal file
View File

@@ -0,0 +1,111 @@
/**
* شِمای شکلِ دستیار. همان قراردادی که بک‌اند (rag/services/diagram.py) اعتبارسنجی
* می‌کند و مدل طبقِ قاعده‌ی پرامپت می‌نویسد. هر تغییر اینجا، آنجا هم لازم است.
*
* اصل: مدل «چه» را می‌گوید (نقطه‌ی وسط، پای عمود، ریشه‌های تابع)، کدِ اپ «کجا» را
* حساب می‌کند. هیچ مختصاتی که بشود حساب کرد از مدل گرفته نمی‌شود.
*/
export type ColorName = 'blue' | 'red' | 'green' | 'orange' | 'purple' | 'teal' | 'pink' | 'gray' | 'black' | 'yellow';
export type Ref = string | [number, number];
/** تعریفِ نقطه: مختصات، یا ساختنی از نقطه‌های دیگر. */
export type PointDef =
| [number, number]
| { polar: [number, number]; from?: Ref }
| { mid: [Ref, Ref] }
| { foot: [Ref, Ref, Ref] }
| { intersect: [[Ref, Ref], [Ref, Ref]] }
| { along: [Ref, Ref, number] }
| { fx: [string, number] };
interface Style {
color?: ColorName;
dashed?: boolean;
label?: string;
}
export type Transform =
| { rotate: { center: Ref; deg: number } }
| { reflect: 'x' | 'y' | 'y=x' | [Ref, Ref] }
| { translate: [number, number] }
| { scale: { center: Ref; k: number } };
export type FunctionMark = 'roots' | 'vertex' | 'extrema' | 'yIntercept' | 'axis';
export type PlaneItem =
| ({ type: 'point'; point: Ref; open?: boolean } & Style)
| ({ type: 'segment'; from: Ref; to: Ref; ticks?: number } & Style)
| ({ type: 'arrow' | 'vector'; from: Ref; to: Ref } & Style)
| ({ type: 'line'; through?: [Ref, Ref]; /** ax + by = c */ eq?: [number, number, number] } & Style)
| ({ type: 'ray'; from: Ref; through: Ref } & Style)
| ({ type: 'vline'; x: number } & Style)
| ({ type: 'hline'; y: number } & Style)
| ({ type: 'circle'; center: Ref; r?: number; through?: Ref; fill?: boolean } & Style)
| ({ type: 'arc' | 'sector'; center: Ref; r: number; start: number; end: number } & Style)
| ({ type: 'angle'; vertex: Ref; from: Ref; to: Ref; arcs?: number } & Style)
| ({ type: 'rightAngle'; vertex: Ref; from: Ref; to: Ref } & Style)
| ({ type: 'polygon'; points: Ref[]; fill?: boolean } & Style)
| ({ type: 'function'; expr: string; from: number; to: number; marks?: FunctionMark[] } & Style)
| ({ type: 'piecewise'; pieces: { expr: string; from: number; to: number; closedFrom?: boolean; closedTo?: boolean }[] } & Style)
| ({ type: 'tangent'; expr: string; at: number } & Style)
| ({ type: 'area'; expr: string; expr2?: string; from: number; to: number } & Style)
| ({ type: 'region'; expr: string; side: 'above' | 'below'; from?: number; to?: number; strict?: boolean } & Style)
| ({ type: 'image'; of: Ref[]; transform: Transform; names?: string[] } & Style)
| { type: 'text'; at: [number, number]; text: string; color?: ColorName };
export interface PlaneSpec {
kind: 'plane';
title: string;
axes: boolean;
grid?: boolean;
/** محورِ x با مضرب‌های π/2 */
trig?: boolean;
xRange?: [number, number];
yRange?: [number, number];
points: Record<string, PointDef>;
items: PlaneItem[];
}
export interface NumberLineSpec {
kind: 'numberline';
title: string;
min: number;
max: number;
step?: number;
/** برچسبِ تیک‌ها به‌صورتِ کسر (۱/۴، ۲/۴، …) */
fractions?: boolean;
points: { at: number; label: string; open?: boolean; color?: ColorName }[];
jumps: { from: number; to: number; label: string }[];
intervals?: { from: number | null; to: number | null; closedFrom?: boolean; closedTo?: boolean; color?: ColorName; label?: string }[];
}
export interface FractionSpec {
kind: 'fraction'; title: string; shape: 'circle' | 'rect'; parts: number; shaded: number;
/** واحدهای کاملِ عددِ مخلوط (دو و سه‌چهارم → ۲) */
wholes?: number;
/** مقایسه‌ی چند کسر با نوارهای هم‌اندازه */
compare?: { parts: number; shaded: number; label?: string }[];
}
export interface GridSpec { kind: 'grid'; title: string; rows: number; cols: number; shaded: number | [number, number][]; color?: ColorName }
export interface GroupsSpec { kind: 'groups'; title: string; groups: number; each: number; item?: 'circle' | 'square' | 'star'; label?: string }
export interface ClockSpec { kind: 'clock'; title: string; hour: number; minute: number }
export interface ChartSpec { kind: 'bar' | 'linechart' | 'pie'; title: string; labels: string[]; values: number[]; unit?: string }
export interface TableSpec { kind: 'table'; title: string; headers: string[]; rows: string[][] }
export interface VennSpec { kind: 'venn'; title: string; sets: string[]; regions: Record<string, string[]>; shade?: string[]; /** نامِ نمایشیِ مجموعه‌ها («والیبال»)؛ بی‌آن همان A، B، C */ names?: string[] }
export interface MappingSpec { kind: 'mapping'; title: string; left: string[]; right: string[]; arrows: [number, number][]; leftLabel?: string; rightLabel?: string }
export interface TreeNode { label: string; p?: string; children?: TreeNode[] }
export interface TreeSpec { kind: 'tree'; title: string; root: TreeNode }
export interface SolidSpec {
kind: 'solid';
title: string;
shape: 'cube' | 'cuboid' | 'prism' | 'pyramid' | 'cylinder' | 'cone' | 'sphere' | 'hemisphere';
/** a پهنا، b عمق، c ارتفاع (مکعب‌مستطیل)؛ n تعدادِ ضلعِ قاعده؛ r شعاع؛ h ارتفاع */
dims?: { a?: number; b?: number; c?: number; n?: number; r?: number; h?: number };
labels?: { a?: string; b?: string; c?: string; r?: string; h?: string };
}
export type DiagramSpec =
| PlaneSpec | NumberLineSpec | FractionSpec | GridSpec | GroupsSpec | ClockSpec
| ChartSpec | TableSpec | VennSpec | MappingSpec | TreeSpec | SolidSpec;

43
src/lib/MathText.tsx Normal file
View File

@@ -0,0 +1,43 @@
import { Fragment } from 'react';
/**
* متنِ فارسی که ریاضی لایش است («عدد −۱ می‌شود»، «رأس (۱، −۴) است»).
*
* رقمِ فارسی در الگوریتمِ جهتِ یونیکد «عددِ عربی» است و در متنِ راست‌به‌چپ علامت‌های
* کنارش را جابه‌جا می‌کند: «−۱» به‌صورتِ «۱−» و «(۱، −۴)» به‌صورتِ «(−۴ ،۱)» دیده
* می‌شد. هر تکه‌ای که حرفِ فارسی ندارد ولی رقم دارد، اینجا با bidi-override چپ‌به‌راست
* قفل می‌شود؛ جمله‌ی فارسی دورش دست نمی‌خورد.
*
* آزموده روی Chrome (دسکتاپ و موبایلِ شبیه‌سازی‌شده)؛ روی Safariِ واقعیِ آیفون دیده نشده.
*/
// حروف، اِعراب (کسره‌ی «خودِ»)، و نیم‌فاصله؛ رقم‌های ۰–۹ عمداً بیرون‌اند.
const LETTER = '\\u0620-\\u065F\\u066E-\\u06D3\\u06FA-\\u06FF\\u200C';
// تکه‌ای بدونِ حرفِ فارسی و بدونِ خطِ تازه که دستِ‌کم یک رقم دارد
const RUN = new RegExp(`[^${LETTER}\\n]*[0-9۰-۹][^${LETTER}\\n]*`, 'g');
// نشانه‌هایی که مالِ جمله‌ی فارسی‌اند، نه ریاضی: از دو سرِ تکه بیرون می‌مانند
const EDGE_START = /^[\s،؛:!؟«»"'.=]+/;
const EDGE_END = /[\s،؛:!؟«»"'.=]+$/;
const LTR = { unicodeBidi: 'bidi-override', direction: 'ltr' } as const;
export function MathText({ text }: { text: string }) {
const out: React.ReactNode[] = [];
let last = 0, i = 0;
for (const m of text.matchAll(RUN)) {
const start = m.index ?? 0;
let run = m[0];
// شماره‌ی مرحله در سرِ خط («۴) …») مالِ جمله‌ی فارسی است و باید سمتِ راست بماند.
const atLineStart = start === 0 || text[start - 1] === '\n';
const step = atLineStart ? /^\s*[0-9۰-۹]{1,2}\)\s*/.exec(run)?.[0] ?? '' : '';
const lead = step + (EDGE_START.exec(run.slice(step.length))?.[0] ?? '');
run = run.slice(lead.length);
const tail = EDGE_END.exec(run)?.[0] ?? '';
run = run.slice(0, run.length - tail.length);
// «(۱، −۴)» باید کامل قفل شود، ولی پرانتزِ بازِ بی‌جفت در سرِ تکه مالِ جمله است.
if (!run || !/[0-9۰-۹]/.test(run)) continue;
out.push(text.slice(last, start + lead.length));
out.push(<span key={i++} style={LTR}>{run}</span>);
last = start + lead.length + run.length;
}
out.push(text.slice(last));
return <>{out.map((n, j) => (typeof n === 'string' ? <Fragment key={`t${j}`}>{n}</Fragment> : n))}</>;
}

View File

@@ -1,10 +1,20 @@
import { StrictMode } from 'react'; import { StrictMode, lazy, Suspense } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { App } from './app/App'; import { App } from './app/App';
import './styles/index.css'; import './styles/index.css';
// گالریِ شکل‌ها فقط در حالتِ توسعه؛ در build اصلاً وارد نمی‌شود (import.meta.env.DEV ثابت است).
const DiagramGallery = import.meta.env.DEV
? lazy(() => import('./dev/DiagramGallery').then((m) => ({ default: m.DiagramGallery })))
: null;
const showGallery = DiagramGallery && new URLSearchParams(window.location.search).has('gallery');
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<App /> {showGallery && DiagramGallery ? (
<Suspense fallback={null}><DiagramGallery /></Suspense>
) : (
<App />
)}
</StrictMode>, </StrictMode>,
); );

View File

@@ -7,7 +7,8 @@ import { askStore, useAskState, type ChatMessage } from '@/state/askStore';
import { SeyarMascot } from '@/mascots/SeyarMascot'; import { SeyarMascot } from '@/mascots/SeyarMascot';
import { useToast } from '@/components/Toast'; import { useToast } from '@/components/Toast';
import { Screen, ScreenHeader } from '@/components/ui'; import { Screen, ScreenHeader } from '@/components/ui';
import { Diagram } from '@/components/Diagram'; import { Diagram } from '@/diagram';
import { MathText } from '@/lib/MathText';
import { friendlyError } from '@/lib/errors'; import { friendlyError } from '@/lib/errors';
import { toFa } from '@/lib/format'; import { toFa } from '@/lib/format';
@@ -222,7 +223,7 @@ function Message({
}) { }) {
const toast = useToast(); const toast = useToast();
if (message.role === 'user') return <Bubble role="user">{message.text}</Bubble>; if (message.role === 'user') return <Bubble role="user"><MathText text={message.text} /></Bubble>;
if (!message.text) { if (!message.text) {
return ( return (
@@ -255,7 +256,7 @@ function Message({
return ( return (
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Bubble role="assistant" extra={message.diagram ? <div className="whitespace-normal"><Diagram spec={message.diagram} /></div> : undefined}> <Bubble role="assistant" extra={message.diagram ? <div className="whitespace-normal"><Diagram spec={message.diagram} /></div> : undefined}>
{message.text} <MathText text={message.text} />
</Bubble> </Bubble>
<div className="flex flex-wrap items-center gap-2 pr-10"> <div className="flex flex-wrap items-center gap-2 pr-10">
{message.failedQuestion ? ( {message.failedQuestion ? (

View File

@@ -1,6 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { BookOpen, Flame, Link2, LogOut, Star, Trophy, UserPlus } from 'lucide-react'; import { BookOpen, Flame, Link2, LogOut, Star, Trophy, UserPlus } from 'lucide-react';
import { useNav } from '@/nav/Navigator'; import { useNav } from '@/nav/Navigator';
import { useAppData } from '@/state/AppData';
import { api, ApiError, type FamilyLink, type FamilyWeeklyReport } from '@/api'; import { api, ApiError, type FamilyLink, type FamilyWeeklyReport } from '@/api';
import { useAsync } from '@/lib/useAsync'; import { useAsync } from '@/lib/useAsync';
import { useToast } from '@/components/Toast'; import { useToast } from '@/components/Toast';
@@ -15,6 +16,11 @@ import { digitsOnly, formatJalaliDate, toFa } from '@/lib/format';
*/ */
export function FamilyScreen({ onSignedOut }: { onSignedOut: () => void }) { export function FamilyScreen({ onSignedOut }: { onSignedOut: () => void }) {
const nav = useNav(); const nav = useNav();
const { profile } = useAppData();
// دانش‌آموز (پایه دارد) پیش از اتصال فقط «کدِ دعوت» را می‌بیند؛ فرمِ والد برای او پشتِ یک
// پیوند است. قبلاً هر دو فرم با هم بود و بچه نمی‌دانست کدام مالِ اوست. والد پایه ندارد.
const isStudent = profile.gradeId !== null;
const [showParentForm, setShowParentForm] = useState(false);
// GET /family/link نمی‌گوید کاربر در این اتصال والد است یا فرزند؛ گزارشِ // GET /family/link نمی‌گوید کاربر در این اتصال والد است یا فرزند؛ گزارشِ
// هفتگی فقط برای والد جواب می‌دهد و برای فرزند ۴۰۴ است، پس نقش از همان درمی‌آید. // هفتگی فقط برای والد جواب می‌دهد و برای فرزند ۴۰۴ است، پس نقش از همان درمی‌آید.
const state = useAsync(async () => { const state = useAsync(async () => {
@@ -34,7 +40,7 @@ export function FamilyScreen({ onSignedOut }: { onSignedOut: () => void }) {
return ( return (
<Screen> <Screen>
<ScreenHeader title="بخشِ والدین" subtitle="پیشرفتِ فرزندت رو هر هفته ببین" color="#FF7A3D" back mascot={{ char: 'sigma', mood: 'happy', size: 56 }} /> <ScreenHeader title="بخشِ والدین" subtitle={isStudent ? 'به پدر یا مادرت وصل شو تا پیشرفتت رو ببینن' : 'پیشرفتِ فرزندت رو هر هفته ببین'} color="#FF7A3D" back mascot={{ char: 'sigma', mood: 'happy', size: 56 }} />
<ScreenBody className="flex flex-col gap-4"> <ScreenBody className="flex flex-col gap-4">
{state.loading ? ( {state.loading ? (
<LoadingState /> <LoadingState />
@@ -44,8 +50,14 @@ export function FamilyScreen({ onSignedOut }: { onSignedOut: () => void }) {
<> <>
{data?.link && <LinkStatus link={data.link} role={data.role} />} {data?.link && <LinkStatus link={data.link} role={data.role} />}
{data?.report && <WeeklyReport report={data.report} />} {data?.report && <WeeklyReport report={data.report} />}
{data?.role !== 'child' && <InviteChild onDone={state.reload} />} {!data?.role && isStudent && <AcceptInvite onDone={state.reload} />}
{!data?.role && <AcceptInvite onDone={state.reload} />} {data?.role !== 'child' && (!isStudent || showParentForm || data?.role === 'parent' || data?.link?.status === 'pending') && <InviteChild onDone={state.reload} />}
{!data?.role && !isStudent && <AcceptInvite onDone={state.reload} />}
{!data?.role && isStudent && !showParentForm && data?.link?.status !== 'pending' && (
<button type="button" onClick={() => setShowParentForm(true)} className="py-1 text-center text-xs font-bold text-muted">
پدر یا مادرِ یک دانش‌آموز هستی؟ از اینجا دعوت بساز
</button>
)}
</> </>
)} )}
{/* والد بدونِ پایه نوارِ پایین را ندارد؛ خروج باید همین‌جا باشد. */} {/* والد بدونِ پایه نوارِ پایین را ندارد؛ خروج باید همین‌جا باشد. */}