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:
106
src/diagram/numberline.tsx
Normal file
106
src/diagram/numberline.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user