forked from Tadrisino/MathApp_frontend
295 lines
17 KiB
TypeScript
295 lines
17 KiB
TypeScript
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>
|
||
);
|
||
}
|