forked from Tadrisino/MathApp_frontend
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:
166
src/diagram/sets.tsx
Normal file
166
src/diagram/sets.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user