Files
MathApp_frontend/src/diagram/misc.tsx

295 lines
17 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 { ClockSpec, FractionSpec, GridSpec, GroupsSpec, PatternSpec, TableSpec } from './types';
import { useContext } from 'react';
import { COLORS, HideCounts, INK, bidi, color, mathText } from './svg';
import { toFa } from '@/lib/format';
const W = 320;
const SHADE = COLORS.yellow;
/* ─── کسر: دایره یا نوارِ تکه‌تکه ─────────────────────────────────────────── */
export function Fraction({ spec }: { spec: FractionSpec }) {
const hide = useContext(HideCounts);
if (spec.compare?.length) return <FractionCompare items={spec.compare} hide={hide} />;
if (spec.wholes) return <MixedNumber wholes={spec.wholes} parts={spec.parts} shaded={spec.shaded} shape={spec.shape} hide={hide} />;
const { parts, shaded } = spec;
const fill = (i: number) => (i < shaded ? SHADE : '#fff');
const caption = hide ? null : <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, hide }: { items: { parts: number; shaded: number; label?: string }[]; hide: boolean }) {
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 ?? (hide ? '' : `${toFa(f.shaded)} از ${toFa(f.parts)}`)}
</text>
</g>
);
})}
</svg>
);
}
/** عددِ مخلوط: چند واحدِ کامل و یک کسر (دو و هفت‌دهم). */
function MixedNumber({ wholes, parts, shaded, shape, hide }: { wholes: number; parts: number; shaded: number; shape: 'circle' | 'rect'; hide: boolean }) {
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>
{!hide && <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 hide = useContext(HideCounts);
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} />
{!hide && <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 hide = useContext(HideCounts);
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>
{(spec.label != null || !hide) && <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>
);
}
/* ─── الگوی شکلی: شکلِ ۱، ۲، ۳، … و «شکلِ بعدی؟» (فصلِ الگو و دنباله) ────────── */
/** خانه‌های هر مرحله در واحدِ شبکه؛ اولین مرحله راست است، مثلِ کتاب. */
function patternCells(layout: PatternSpec['layout'], n: number): [number, number][] {
const out: [number, number][] = [];
if (layout === 'square') for (let r = 0; r < n; r++) for (let c = 0; c < n; c++) out.push([c, r]);
else if (layout === 'rect') for (let r = 0; r < n; r++) for (let c = 0; c <= n; c++) out.push([c, r]);
else if (layout === 'triangle') for (let r = 0; r < n; r++) for (let c = 0; c <= r; c++) out.push([c + (n - 1 - r) / 2, r]);
else if (layout === 'stair') for (let c = 0; c < n; c++) for (let r = 0; r <= c; r++) out.push([c, n - 1 - r]);
else if (layout === 'L') { for (let r = 0; r < n; r++) out.push([0, r]); for (let c = 1; c < n; c++) out.push([c, n - 1]); }
else for (let c = 0; c < n; c++) out.push([c, 0]);
return out;
}
export function Pattern({ spec }: { spec: PatternSpec }) {
const hide = useContext(HideCounts);
const stages = spec.stages;
const sticks = spec.unit === 'stick';
const sizes = stages.map((n) => {
const cells = sticks ? [] : patternCells(spec.layout, n);
const w = sticks ? n : Math.max(1, ...cells.map(([c]) => c + 1));
const h = sticks ? 1 : Math.max(1, ...cells.map(([, r]) => r + 1));
return { n, cells, w, h };
});
const gapU = 1.2;
const totalU = sizes.reduce((t, s) => t + s.w, 0) + gapU * (sizes.length - 1 + (spec.next ? 1 : 0)) + (spec.next ? 1.4 : 0);
const maxH = Math.max(...sizes.map((s) => s.h));
const u = Math.min(22, (W - 24) / totalU, 150 / maxH);
const H = maxH * u + 46;
const base = 10 + maxH * u;
const col = color(spec.color, COLORS.blue);
let x = W - 12;
const groups = sizes.map((s, i) => {
x -= s.w * u;
const x0 = x;
x -= gapU * u;
const count = sticks ? 3 * s.n + 1 : s.cells.length;
return (
<g key={i}>
{sticks
? Array.from({ length: s.n + 1 }, (_, k) => <line key={`v${k}`} x1={x0 + k * u} y1={base - u + 2} x2={x0 + k * u} y2={base - 2} stroke={COLORS.orange} strokeWidth={3} strokeLinecap="round" />)
.concat(Array.from({ length: s.n }, (_, k) => [
<line key={`t${k}`} x1={x0 + k * u + 2} y1={base - u} x2={x0 + (k + 1) * u - 2} y2={base - u} stroke={COLORS.orange} strokeWidth={3} strokeLinecap="round" />,
<line key={`b${k}`} x1={x0 + k * u + 2} y1={base} x2={x0 + (k + 1) * u - 2} y2={base} stroke={COLORS.orange} strokeWidth={3} strokeLinecap="round" />,
]).flat())
: s.cells.map(([c, r], k) => {
const cx = x0 + (s.w - 1 - c) * u, cy = base - (s.h - r) * u;
return spec.unit === 'dot'
? <circle key={k} cx={cx + u / 2} cy={cy + u / 2} r={u * 0.32} fill={col} />
: <rect key={k} x={cx + 1} y={cy + 1} width={u - 2} height={u - 2} rx={2} fill={`${col}33`} stroke={col} strokeWidth={1.5} />;
})}
<text x={x0 + (s.w * u) / 2} y={base + 16} fontSize={11} fontWeight={800} fill={INK} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>{`شکل ${toFa(i + 1)}`}</text>
{spec.counts !== false && !hide && <text x={x0 + (s.w * u) / 2} y={base + 30} fontSize={10.5} fill={INK} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>{`${toFa(count)} ${sticks ? 'چوب' : 'تا'}`}</text>}
</g>
);
});
const nx = x - 1.4 * u + gapU * u;
return (
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[340px]" role="img">
{groups}
{spec.next && (
<g>
<rect x={nx} y={base - 1.4 * u} width={1.4 * u} height={1.4 * u} rx={6} fill="#fff" stroke={COLORS.red} strokeWidth={2} strokeDasharray="4 3" />
<text x={nx + 0.7 * u} y={base - 0.45 * u} fontSize={u * 0.8} fontWeight={900} fill={COLORS.red} textAnchor="middle">?</text>
<text x={nx + 0.7 * u} y={base + 16} fontSize={11} fontWeight={800} fill={COLORS.red} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>{`شکل ${toFa(stages.length + 1)}`}</text>
</g>
)}
</svg>
);
}