- 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>
157 lines
9.6 KiB
TypeScript
157 lines
9.6 KiB
TypeScript
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>
|
||
);
|
||
}
|