Files
MathApp_frontend/src/diagram/solid.tsx
AliReza ec4c844531 draw every kind of textbook figure, lock math text left-to-right, student-first family screen
- src/diagram: plane (constructed points, graphs with computed roots, vertex,
  extrema, tangent, asymptotes, piecewise limits, regions, rotations and
  reflections, line from ax + by = c), number line with intervals and stacked
  fractions, solids with hidden edges, bar/line/pie charts, sign tables, Venn,
  arrow mappings, probability trees, fraction bars and mixed numbers, grids,
  groups, clock. Pure-math text is bidi-overridden: Persian digits used to turn
  3 × 4 = 12 into 12 = 4 × 3 even with dir=ltr.
- MathText locks math runs inside Persian sentences in chat bubbles and figure
  captions, so -1 no longer shows as 1-.
- Family screen: a student sees only the invite-code form; the parent form is
  behind a link.
- Dev-only diagram gallery at ?gallery (not in the production build).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 23:24:50 +03:30

157 lines
9.6 KiB
TypeScript
Raw Blame History

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