Files
MathApp_frontend/src/diagram/numberline.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

107 lines
5.8 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 { NumberLineSpec } from './types';
import { Arrowheads, COLORS, INK, Label, SERIES, SOFT, StackedFraction, arrowId, color, fa, gcd, mathText, niceStep, ticksBetween } from './svg';
import { toFa } from '@/lib/format';
const W = 330;
const PAD = 26;
/**
* محورِ اعداد: نقطه (توپُر یا توخالی)، پرش (فلشِ خمیده)، و بازه (پاره‌خطِ رنگی با
* سرِ باز/بسته، یا تا بی‌نهایت). با `fractions` تیک‌ها کسرِ دوطبقه‌اند (۱/۴، ۲/۴)،
* چون در کتابِ دبستان «/» ممیز است.
*/
export function NumberLine({ spec }: { spec: NumberLineSpec }) {
const { min, max } = spec;
const intervals = spec.intervals ?? [];
const lanes = intervals.length;
const jumpH = spec.jumps.length ? 52 : 0;
const LANE = intervals.some((iv) => iv.label) ? 24 : 14;
const base = 22 + jumpH + lanes * LANE + 10;
const H = base + (spec.fractions ? 58 : 44);
const X = (v: number) => PAD + ((v - min) / (max - min)) * (W - 2 * PAD);
// محورِ عددهای صحیح گامِ صحیحِ «گرد» می‌گیرد (۱، ۲، ۵، ۱۰، …)، نه ۲٫۵
const intStep = (span: number) => [1, 2, 5, 10, 20, 25, 50, 100, 200, 500, 1000].find((c) => span / c <= 12) ?? niceStep(span, 12);
const integers = Number.isInteger(min) && Number.isInteger(max);
const step = spec.step && spec.step > 0 && (max - min) / spec.step <= 40 ? spec.step : integers ? intStep(max - min) : niceStep(max - min, 12);
const ticks = ticksBetween(min, max, step);
const labelled = new Set(spec.points.map((p) => p.at));
// بیش از ۱۴ تیک: فقط بعضی عدد می‌گیرند (صفر، مضرب‌های ۵ یا ۱۰، نقطه‌های خواسته‌شده)،
// وگرنه عددها روی هم می‌افتند.
const every = ticks.length > 14 ? Math.ceil(ticks.length / 10) : 1;
const showLabel = (t: number, i: number) => every === 1 || labelled.has(t) || Math.abs(t) < 1e-9 || i % every === 0;
const arrows = new Set<string>([INK, COLORS.red]);
const tickLabel = (t: number) => {
if (spec.fractions && step < 1) {
const den = Math.round(1 / step);
const num = Math.round(t * den);
if (num % den === 0) return null; // عددِ صحیح با رقمِ معمولی
const g = gcd(num, den);
return { num: toFa(Math.abs(num / g)), den: toFa(den / g), neg: num < 0 };
}
return null;
};
return (
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[360px]" role="img" aria-label={spec.title || 'محورِ اعداد'}>
<Arrowheads ids={[...arrows, ...intervals.map((iv, i) => color(iv.color, COLORS[SERIES[i % SERIES.length]]))]} />
<line x1={PAD - 16} y1={base} x2={W - PAD + 16} y2={base} stroke={INK} strokeWidth={2}
markerEnd={`url(#${arrowId(INK)})`} markerStart={`url(#${arrowId(INK)})`} />
{ticks.map((t, ti) => {
const fr = showLabel(t, ti) ? tickLabel(t) : null;
const hideNumber = !showLabel(t, ti);
const on = labelled.has(t);
const whole = Math.abs(t - Math.round(t)) < 1e-9;
return (
<g key={t}>
<line x1={X(t)} x2={X(t)} y1={base - (whole ? 6 : 4)} y2={base + (whole ? 6 : 4)} stroke={INK} strokeWidth={Math.abs(t) < 1e-9 ? 2.2 : 1.3} />
{fr ? (
<StackedFraction x={X(t)} y={base + 16} num={`${fr.neg ? '−' : ''}${fr.num}`} den={fr.den} color={on ? INK : SOFT} size={9} />
) : hideNumber ? null : (
<text x={X(t)} y={base + 20} fontSize={on ? 11.5 : 10} fontWeight={on ? 800 : 500} fill={on ? INK : SOFT} textAnchor="middle">{fa(t)}</text>
)}
</g>
);
})}
{intervals.map((iv, i) => {
const col = color(iv.color, COLORS[SERIES[i % SERIES.length]]);
const y = base - 10 - i * LANE;
const a = iv.from === null ? PAD - 12 : X(iv.from), b = iv.to === null ? W - PAD + 12 : X(iv.to);
return (
<g key={`iv${i}`}>
<line x1={a} x2={b} y1={y} y2={y} stroke={col} strokeWidth={4} strokeLinecap="round"
markerStart={iv.from === null ? `url(#${arrowId(col)})` : undefined} markerEnd={iv.to === null ? `url(#${arrowId(col)})` : undefined} />
{iv.from !== null && <circle cx={a} cy={y} r={4.6} fill={iv.closedFrom ? col : '#fff'} stroke={col} strokeWidth={2.2} />}
{iv.to !== null && <circle cx={b} cy={y} r={4.6} fill={iv.closedTo ? col : '#fff'} stroke={col} strokeWidth={2.2} />}
{iv.label && <Label x={(Math.max(a, PAD) + Math.min(b, W - PAD)) / 2} y={y - 7} color={col} size={10.5}>{mathText(iv.label)}</Label>}
</g>
);
})}
{spec.jumps.map((j, i) => {
const a = X(j.from), b = X(j.to);
const lift = Math.min(42, Math.abs(b - a) * 0.45 + 12);
const y = base - 6 - lanes * LANE;
return (
<g key={`j${i}`}>
<path d={`M${a},${y} Q${(a + b) / 2},${y - lift * 1.5} ${b},${y}`} fill="none" stroke={COLORS.red} strokeWidth={2.3} markerEnd={`url(#${arrowId(COLORS.red)})`} />
{j.label && <Label x={(a + b) / 2} y={y - lift * 0.8 - 4} color={COLORS.red} size={10.5}>{mathText(j.label)}</Label>}
</g>
);
})}
{spec.points.map((p, i) => {
const col = color(p.color, i === spec.points.length - 1 && spec.points.length > 1 ? COLORS.red : COLORS.blue);
const numeric = /^[-−+]?[\d۰-۹.,٫/]+$/.test(p.label.trim());
return (
<g key={`p${i}`}>
<circle cx={X(p.at)} cy={base} r={5.6} fill={p.open ? '#fff' : col} stroke={p.open ? col : '#fff'} strokeWidth={p.open ? 2.4 : 1.6} />
{p.label && !numeric && <Label x={X(p.at)} y={base + (spec.fractions ? 50 : 36)} color={col} size={10.5}>{p.label}</Label>}
</g>
);
})}
</svg>
);
}