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:
@@ -1,392 +0,0 @@
|
||||
import { memo, useMemo, type ReactNode } from 'react';
|
||||
import type { Diagram as DiagramSpec, DiagramItem, DiagramPointDef, DiagramRef } from '@/api';
|
||||
import { parseExpr } from '@/lib/mathExpr';
|
||||
import { toFa } from '@/lib/format';
|
||||
|
||||
/**
|
||||
* شکلِ پاسخِ دستیار، از روی توصیفِ JSONی که بکاند اعتبارسنجی کرده
|
||||
* (rag/services/diagram.py). مختصات همینجا حساب میشوند — مدل فقط میگوید
|
||||
* «نقطه روی دایره با زاویهی ۳۰°» — و همهچیز عنصرِ SVGِ React است، بدونِ
|
||||
* innerHTML؛ پس متنِ مدل هیچ راهی به HTMLِ صفحه ندارد.
|
||||
*
|
||||
* هر ایرادی (نقطهی ناشناخته، عبارتِ نامعتبر) فقط همان تکه را حذف میکند؛ اگر
|
||||
* چیزی برای کشیدن نماند، هیچ شکلی نشان داده نمیشود.
|
||||
*/
|
||||
const INK = '#2b2b45';
|
||||
const MAIN = '#4C5FD5';
|
||||
const ACCENT = '#ED6956';
|
||||
const SOFT = '#9e9ec0';
|
||||
const W = 300;
|
||||
const PAD = 22;
|
||||
|
||||
type XY = [number, number];
|
||||
|
||||
function DiagramBase({ spec }: { spec: DiagramSpec }) {
|
||||
const body = useMemo(() => {
|
||||
try {
|
||||
if (spec.kind === 'numberline') return <NumberLine spec={spec} />;
|
||||
if (spec.kind === 'fraction') return <Fraction spec={spec} />;
|
||||
if (spec.kind === 'plane') return <Plane spec={spec} />;
|
||||
} catch {
|
||||
/* شکلِ خراب نباید پاسخ را خراب کند */
|
||||
}
|
||||
return null;
|
||||
}, [spec]);
|
||||
if (!body) return null;
|
||||
return (
|
||||
// dir=ltr: محور و مختصات چپبهراستاند؛ در زمینهی rtl «−۱» به «۱−» برمیگشت.
|
||||
// برچسبهای فارسی با unicode-bidi: plaintext جهتِ خودشان را میگیرند.
|
||||
<figure dir="ltr" className="mt-2 overflow-hidden rounded-2xl border border-line bg-[#fbfbff] p-2">
|
||||
{body}
|
||||
{spec.title && <figcaption dir="rtl" className="mt-1 text-center text-[11px] font-bold text-muted">{spec.title}</figcaption>}
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
export const Diagram = memo(DiagramBase);
|
||||
|
||||
/* ─── صفحهی مختصات / هندسه ───────────────────────────────────────────────── */
|
||||
|
||||
function Plane({ spec }: { spec: Extract<DiagramSpec, { kind: 'plane' }> }) {
|
||||
// نقطهها: polar نسبت به مبدأ یا نقطهی from، با حلِ ارجاعِ زنجیرهای.
|
||||
const named = new Map<string, XY>();
|
||||
const resolving = new Set<string>();
|
||||
const resolveName = (name: string): XY | null => {
|
||||
if (named.has(name)) return named.get(name)!;
|
||||
const def: DiagramPointDef | undefined = spec.points[name];
|
||||
if (!def || resolving.has(name)) return null;
|
||||
resolving.add(name);
|
||||
let xy: XY | null = null;
|
||||
if (Array.isArray(def)) xy = [def[0], def[1]];
|
||||
else {
|
||||
const origin = def.from !== undefined ? ref(def.from) : [0, 0];
|
||||
if (origin) {
|
||||
const [r, deg] = def.polar;
|
||||
const t = (deg * Math.PI) / 180;
|
||||
xy = [origin[0] + r * Math.cos(t), origin[1] + r * Math.sin(t)];
|
||||
}
|
||||
}
|
||||
resolving.delete(name);
|
||||
if (xy) named.set(name, xy);
|
||||
return xy;
|
||||
};
|
||||
const ref = (r: DiagramRef): XY | null => (typeof r === 'string' ? resolveName(r) : [r[0], r[1]]);
|
||||
Object.keys(spec.points).forEach(resolveName);
|
||||
|
||||
// نمودارِ تابعها پیش از کادر، چون کادر را تعیین میکنند.
|
||||
const curves: { item: Extract<DiagramItem, { type: 'function' }>; pts: XY[] }[] = [];
|
||||
for (const item of spec.items) {
|
||||
if (item.type !== 'function') continue;
|
||||
try {
|
||||
const f = parseExpr(item.expr);
|
||||
const pts: XY[] = [];
|
||||
for (let i = 0; i <= 160; i++) {
|
||||
const x = item.from + ((item.to - item.from) * i) / 160;
|
||||
const y = f(x);
|
||||
if (Number.isFinite(y)) pts.push([x, y]);
|
||||
}
|
||||
if (pts.length > 1) curves.push({ item, pts });
|
||||
} catch {
|
||||
/* عبارتِ نامعتبر: همین نمودار کشیده نمیشود */
|
||||
}
|
||||
}
|
||||
|
||||
// کادر
|
||||
const xs: number[] = [], ys: number[] = [];
|
||||
const add = ([x, y]: XY) => { xs.push(x); ys.push(y); };
|
||||
named.forEach(add);
|
||||
for (const item of spec.items) {
|
||||
if (item.type === 'circle') {
|
||||
const c = ref(item.center);
|
||||
if (c) { add([c[0] - item.r, c[1] - item.r]); add([c[0] + item.r, c[1] + item.r]); }
|
||||
} else if (item.type === 'text') add(item.at);
|
||||
else if (item.type === 'segment' || item.type === 'arrow') {
|
||||
const a = ref(item.from), b = ref(item.to);
|
||||
if (a) add(a);
|
||||
if (b) add(b);
|
||||
} else if (item.type === 'polygon') item.points.forEach((p) => { const xy = ref(p); if (xy) add(xy); });
|
||||
else if (item.type === 'point') { const xy = ref(item.point); if (xy) add(xy); }
|
||||
}
|
||||
for (const { item, pts } of curves) {
|
||||
xs.push(item.from, item.to);
|
||||
const cy = pts.map((p) => p[1]).sort((a, b) => a - b);
|
||||
// دُمِ نمودار (مثلاً tan) نباید کلِ کادر را بکشد: ۵ تا ۹۵ درصد.
|
||||
ys.push(cy[Math.floor(cy.length * 0.05)], cy[Math.ceil(cy.length * 0.95) - 1]);
|
||||
}
|
||||
if (!xs.length) return null;
|
||||
if (spec.axes) { xs.push(0); ys.push(0); }
|
||||
let [x0, x1] = [Math.min(...xs), Math.max(...xs)];
|
||||
let [y0, y1] = [Math.min(...ys), Math.max(...ys)];
|
||||
const span = Math.max(x1 - x0, y1 - y0, 1e-6);
|
||||
const margin = span * 0.14 + 0.2;
|
||||
x0 -= margin; x1 += margin; y0 -= margin; y1 += margin;
|
||||
const scale = (W - 2 * PAD) / (x1 - x0);
|
||||
const height = Math.min(Math.max((y1 - y0) * scale + 2 * PAD, 150), 340);
|
||||
// اگر ارتفاع سقف خورد، مقیاس را از روی ارتفاع بگیر تا دایره بیضی نشود.
|
||||
const s = Math.min(scale, (height - 2 * PAD) / (y1 - y0));
|
||||
const ox = (W - (x1 - x0) * s) / 2, oy = (height - (y1 - y0) * s) / 2;
|
||||
const X = (x: number) => ox + (x - x0) * s;
|
||||
const Y = (y: number) => oy + (y1 - y) * s;
|
||||
const P = ([x, y]: XY): XY => [X(x), Y(y)];
|
||||
|
||||
const els: ReactNode[] = [];
|
||||
let key = 0;
|
||||
const k = () => key++;
|
||||
|
||||
if (spec.axes) {
|
||||
const ticks = niceTicks(x0, x1), yticks = niceTicks(y0, y1);
|
||||
els.push(
|
||||
<g key={k()} stroke="#d9d7ea" strokeWidth={1}>
|
||||
{ticks.map((t) => <line key={`gx${t}`} x1={X(t)} x2={X(t)} y1={Y(y0)} y2={Y(y1)} strokeDasharray="2 4" />)}
|
||||
{yticks.map((t) => <line key={`gy${t}`} y1={Y(t)} y2={Y(t)} x1={X(x0)} x2={X(x1)} strokeDasharray="2 4" />)}
|
||||
</g>,
|
||||
<g key={k()} stroke={SOFT} strokeWidth={1.4} markerEnd="url(#dg-arrow-soft)">
|
||||
<line x1={X(x0)} y1={Y(0)} x2={X(x1)} y2={Y(0)} />
|
||||
<line x1={X(0)} y1={Y(y0)} x2={X(0)} y2={Y(y1)} />
|
||||
</g>,
|
||||
<g key={k()} fill={SOFT} fontSize={9} textAnchor="middle">
|
||||
{ticks.filter((t) => t !== 0).map((t) => <text key={`tx${t}`} x={X(t)} y={Y(0) + 11}>{fa(t)}</text>)}
|
||||
{yticks.filter((t) => t !== 0).map((t) => <text key={`ty${t}`} x={X(0) - 8} y={Y(t) + 3}>{fa(t)}</text>)}
|
||||
</g>,
|
||||
);
|
||||
}
|
||||
|
||||
const centroid: XY = (() => {
|
||||
const all = [...named.values()];
|
||||
if (!all.length) return [0, 0];
|
||||
return [all.reduce((a, p) => a + p[0], 0) / all.length, all.reduce((a, p) => a + p[1], 0) / all.length];
|
||||
})();
|
||||
|
||||
for (const item of spec.items) {
|
||||
if (item.type === 'circle') {
|
||||
const c = ref(item.center);
|
||||
if (!c) continue;
|
||||
const [cx, cy] = P(c);
|
||||
els.push(<circle key={k()} cx={cx} cy={cy} r={item.r * s} fill="none" stroke={MAIN} strokeWidth={2.2} />);
|
||||
} else if (item.type === 'segment' || item.type === 'arrow') {
|
||||
const a = ref(item.from), b = ref(item.to);
|
||||
if (!a || !b) continue;
|
||||
const [ax, ay] = P(a), [bx, by] = P(b);
|
||||
els.push(
|
||||
<line key={k()} x1={ax} y1={ay} x2={bx} y2={by} stroke={item.type === 'arrow' ? ACCENT : INK} strokeWidth={2.2}
|
||||
strokeLinecap="round" markerEnd={item.type === 'arrow' ? 'url(#dg-arrow)' : undefined} />,
|
||||
);
|
||||
if (item.label) els.push(sideLabel(k(), [ax, ay], [bx, by], P(centroid), item.label));
|
||||
} else if (item.type === 'line') {
|
||||
const a = ref(item.through[0]), b = ref(item.through[1]);
|
||||
if (!a || !b || (a[0] === b[0] && a[1] === b[1])) continue;
|
||||
const [dx, dy] = [b[0] - a[0], b[1] - a[1]];
|
||||
const t = (x1 - x0 + y1 - y0) * 2 / Math.hypot(dx, dy);
|
||||
const [px, py] = P([a[0] - dx * t, a[1] - dy * t]), [qx, qy] = P([a[0] + dx * t, a[1] + dy * t]);
|
||||
els.push(<line key={k()} x1={px} y1={py} x2={qx} y2={qy} stroke={MAIN} strokeWidth={1.8} />);
|
||||
} else if (item.type === 'polygon') {
|
||||
const pts = item.points.map(ref).filter((p): p is XY => p !== null).map(P);
|
||||
if (pts.length < 3) continue;
|
||||
els.push(<polygon key={k()} points={pts.map((p) => p.join(',')).join(' ')} fill="#4C5FD514" stroke={INK} strokeWidth={2.2} strokeLinejoin="round" />);
|
||||
} else if (item.type === 'angle' || item.type === 'rightAngle') {
|
||||
const v = ref(item.vertex), a = ref(item.from), b = ref(item.to);
|
||||
if (!v || !a || !b) continue;
|
||||
const [vx, vy] = P(v), [ax, ay] = P(a), [bx, by] = P(b);
|
||||
const ta = Math.atan2(ay - vy, ax - vx), tb = Math.atan2(by - vy, bx - vx);
|
||||
let d = tb - ta;
|
||||
while (d <= -Math.PI) d += 2 * Math.PI;
|
||||
while (d > Math.PI) d -= 2 * Math.PI;
|
||||
const r = Math.min(24, Math.hypot(ax - vx, ay - vy) * 0.4, Math.hypot(bx - vx, by - vy) * 0.4);
|
||||
if (item.type === 'rightAngle') {
|
||||
const ua: XY = [Math.cos(ta), Math.sin(ta)], ub: XY = [Math.cos(tb), Math.sin(tb)];
|
||||
const q = Math.min(r, 12);
|
||||
const p1: XY = [vx + ua[0] * q, vy + ua[1] * q], p3: XY = [vx + ub[0] * q, vy + ub[1] * q];
|
||||
const p2: XY = [p1[0] + ub[0] * q, p1[1] + ub[1] * q];
|
||||
els.push(<polyline key={k()} points={[p1, p2, p3].map((p) => p.join(',')).join(' ')} fill="none" stroke={ACCENT} strokeWidth={1.8} />);
|
||||
} else {
|
||||
const end: XY = [vx + r * Math.cos(ta + d), vy + r * Math.sin(ta + d)];
|
||||
const start: XY = [vx + r * Math.cos(ta), vy + r * Math.sin(ta)];
|
||||
els.push(
|
||||
<path key={k()} d={`M${vx},${vy} L${start[0]},${start[1]} A${r},${r} 0 0 ${d > 0 ? 1 : 0} ${end[0]},${end[1]} Z`}
|
||||
fill="#ED695626" stroke={ACCENT} strokeWidth={1.8} />,
|
||||
);
|
||||
}
|
||||
if (item.label) {
|
||||
const mid = ta + d / 2, lr = r + 13;
|
||||
els.push(<Label key={k()} x={vx + lr * Math.cos(mid)} y={vy + lr * Math.sin(mid) + 4} color={ACCENT}>{item.label}</Label>);
|
||||
}
|
||||
} else if (item.type === 'text') {
|
||||
const [tx, ty] = P(item.at);
|
||||
els.push(<Label key={k()} x={tx} y={ty} color={INK}>{item.text}</Label>);
|
||||
}
|
||||
}
|
||||
|
||||
for (const { item, pts } of curves) {
|
||||
const clipped = pts.filter(([, y]) => y >= y0 - (y1 - y0) && y <= y1 + (y1 - y0));
|
||||
const d = clipped.map((p, i) => `${i ? 'L' : 'M'}${X(p[0]).toFixed(1)},${Y(p[1]).toFixed(1)}`).join(' ');
|
||||
els.push(<path key={k()} d={d} fill="none" stroke={MAIN} strokeWidth={2.4} strokeLinejoin="round" />);
|
||||
if (item.label) {
|
||||
const last = clipped[Math.floor(clipped.length * 0.85)] ?? clipped[clipped.length - 1];
|
||||
els.push(<Label key={k()} x={X(last[0])} y={Y(last[1]) - 8} color={MAIN}>{item.label}</Label>);
|
||||
}
|
||||
}
|
||||
|
||||
// نقطهها آخر از همه، روی بقیه
|
||||
const pointLabels = new Map<string, string>();
|
||||
const extraPoints: { xy: XY; label: string }[] = [];
|
||||
for (const item of spec.items) {
|
||||
if (item.type !== 'point') continue;
|
||||
if (typeof item.point === 'string') pointLabels.set(item.point, item.label ?? item.point);
|
||||
else extraPoints.push({ xy: item.point, label: item.label ?? '' });
|
||||
}
|
||||
const drawPoint = (xy: XY, label: string) => {
|
||||
const [px, py] = P(xy);
|
||||
const [cx, cy] = P(centroid);
|
||||
let [dx, dy] = [px - cx, py - cy];
|
||||
const n = Math.hypot(dx, dy) || 1;
|
||||
[dx, dy] = [(dx / n) * 13, (dy / n) * 13 || -13];
|
||||
els.push(<circle key={k()} cx={px} cy={py} r={3.6} fill={INK} />);
|
||||
if (label) els.push(<Label key={k()} x={px + dx} y={py + dy + 4} color={INK} bold>{label}</Label>);
|
||||
};
|
||||
named.forEach((xy, name) => {
|
||||
if (name.startsWith('_')) return;
|
||||
const isOriginOnAxes = spec.axes && xy[0] === 0 && xy[1] === 0 && !pointLabels.has(name);
|
||||
drawPoint(xy, isOriginOnAxes ? '' : pointLabels.get(name) ?? name);
|
||||
});
|
||||
extraPoints.forEach((p) => drawPoint(p.xy, p.label));
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${W} ${height}`} className="mx-auto block w-full max-w-[320px]" role="img" aria-label={spec.title || 'شکل'}>
|
||||
<Markers />
|
||||
{els}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── محورِ اعداد ─────────────────────────────────────────────────────────── */
|
||||
|
||||
function NumberLine({ spec }: { spec: Extract<DiagramSpec, { kind: 'numberline' }> }) {
|
||||
const { min, max } = spec;
|
||||
const height = spec.jumps.length ? 120 : 80;
|
||||
const base = height - 38;
|
||||
const X = (v: number) => PAD + ((v - min) / (max - min)) * (W - 2 * PAD);
|
||||
const step = niceStep(max - min, 12);
|
||||
const ticks: number[] = [];
|
||||
for (let t = Math.ceil(min / step) * step; t <= max + 1e-9; t += step) ticks.push(Math.round(t * 1e6) / 1e6);
|
||||
const labelled = new Set(spec.points.map((p) => p.at));
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${W} ${height}`} className="mx-auto block w-full max-w-[340px]" role="img" aria-label={spec.title || 'محورِ اعداد'}>
|
||||
<Markers />
|
||||
<line x1={PAD - 12} y1={base} x2={W - PAD + 12} y2={base} stroke={INK} strokeWidth={2} markerEnd="url(#dg-arrow-ink)" markerStart="url(#dg-arrow-ink-start)" />
|
||||
{ticks.map((t) => (
|
||||
<g key={t}>
|
||||
<line x1={X(t)} x2={X(t)} y1={base - 5} y2={base + 5} stroke={INK} strokeWidth={t === 0 ? 2 : 1.3} />
|
||||
<text x={X(t)} y={base + 19} fontSize={labelled.has(t) ? 11 : 10} fontWeight={labelled.has(t) ? 800 : 400} fill={labelled.has(t) ? INK : SOFT} textAnchor="middle">
|
||||
{fa(t)}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{spec.jumps.map((j, i) => {
|
||||
const a = X(j.from), b = X(j.to);
|
||||
const lift = Math.min(46, Math.abs(b - a) * 0.5 + 14);
|
||||
return (
|
||||
<g key={`j${i}`}>
|
||||
<path d={`M${a},${base - 6} Q${(a + b) / 2},${base - 6 - lift * 1.4} ${b},${base - 6}`} fill="none" stroke={ACCENT} strokeWidth={2.2} markerEnd="url(#dg-arrow)" />
|
||||
{j.label && <Label x={(a + b) / 2} y={base - 10 - lift * 0.75} color={ACCENT}>{j.label}</Label>}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{spec.points.map((p, i) => (
|
||||
<g key={`p${i}`}>
|
||||
<circle cx={X(p.at)} cy={base} r={5.5} fill={i === spec.points.length - 1 && spec.points.length > 1 ? ACCENT : MAIN} stroke="#fff" strokeWidth={1.5} />
|
||||
{p.label && !/^[-−]?[\d۰-۹.]+$/.test(p.label) && <Label x={X(p.at)} y={base + 33} color={INK}>{p.label}</Label>}
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── کسر ─────────────────────────────────────────────────────────────────── */
|
||||
|
||||
function Fraction({ spec }: { spec: Extract<DiagramSpec, { kind: 'fraction' }> }) {
|
||||
const { parts, shaded } = spec;
|
||||
const fill = (i: number) => (i < shaded ? '#FFC63D' : '#fff');
|
||||
if (spec.shape === 'rect') {
|
||||
const w = (W - 2 * PAD) / parts;
|
||||
return (
|
||||
<svg viewBox={`0 0 ${W} 90`} className="mx-auto block w-full max-w-[320px]" role="img" aria-label={spec.title || 'کسر'}>
|
||||
{Array.from({ length: parts }, (_, i) => (
|
||||
<rect key={i} x={W - PAD - (i + 1) * w} y={18} width={w} height={46} fill={fill(i)} stroke={INK} strokeWidth={2} />
|
||||
))}
|
||||
<text x={W / 2} y={84} fontSize={12} fontWeight={800} fill={INK} textAnchor="middle">{toFa(shaded)} از {toFa(parts)}</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
const cx = W / 2, cy = 82, r = 64;
|
||||
return (
|
||||
<svg viewBox={`0 0 ${W} 170`} className="mx-auto block w-full max-w-[260px]" role="img" aria-label={spec.title || 'کسر'}>
|
||||
{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;
|
||||
const large = a1 - a0 > Math.PI ? 1 : 0;
|
||||
return (
|
||||
<path key={i} d={`M${cx},${cy} L${cx + r * Math.cos(a0)},${cy + r * Math.sin(a0)} A${r},${r} 0 ${large} 1 ${cx + r * Math.cos(a1)},${cy + r * Math.sin(a1)} Z`}
|
||||
fill={fill(i)} stroke={INK} strokeWidth={2} strokeLinejoin="round" />
|
||||
);
|
||||
})
|
||||
)}
|
||||
<text x={cx} y={164} fontSize={12} fontWeight={800} fill={INK} textAnchor="middle">{toFa(shaded)} از {toFa(parts)} تکه</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── کمکیها ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
function Markers() {
|
||||
const head = (id: string, color: string, start = false) => (
|
||||
<marker id={id} viewBox="0 0 10 10" refX={start ? 2 : 8} refY="5" markerWidth="7" markerHeight="7" orient={start ? 'auto-start-reverse' : 'auto'}>
|
||||
<path d="M0,0 L10,5 L0,10 z" fill={color} />
|
||||
</marker>
|
||||
);
|
||||
return (
|
||||
<defs>
|
||||
{head('dg-arrow', ACCENT)}
|
||||
{head('dg-arrow-soft', SOFT)}
|
||||
{head('dg-arrow-ink', INK)}
|
||||
{head('dg-arrow-ink-start', INK, true)}
|
||||
</defs>
|
||||
);
|
||||
}
|
||||
|
||||
function Label({ x, y, color, bold = false, children }: { x: number; y: number; color: string; bold?: boolean; children: ReactNode }) {
|
||||
return (
|
||||
<text x={x} y={y} fontSize={11} fontWeight={bold ? 800 : 700} fill={color} textAnchor="middle"
|
||||
stroke="#fbfbff" strokeWidth={3} paintOrder="stroke" style={{ unicodeBidi: 'plaintext' }}>
|
||||
{children}
|
||||
</text>
|
||||
);
|
||||
}
|
||||
|
||||
/** برچسبِ ضلع، وسطِ پارهخط و به سمتِ بیرونِ شکل. */
|
||||
function sideLabel(key: number, a: XY, b: XY, center: XY, text: string) {
|
||||
const mx = (a[0] + b[0]) / 2, my = (a[1] + b[1]) / 2;
|
||||
let nx = -(b[1] - a[1]), ny = b[0] - a[0];
|
||||
const n = Math.hypot(nx, ny) || 1;
|
||||
nx /= n; ny /= n;
|
||||
if ((mx - center[0]) * nx + (my - center[1]) * ny < 0) { nx = -nx; ny = -ny; }
|
||||
return <Label key={key} x={mx + nx * 13} y={my + ny * 13 + 4} color={MAIN}>{text}</Label>;
|
||||
}
|
||||
|
||||
function niceStep(span: number, maxTicks: number): number {
|
||||
const raw = span / maxTicks;
|
||||
const pow = 10 ** Math.floor(Math.log10(raw || 1));
|
||||
const m = raw / pow;
|
||||
return (m <= 1 ? 1 : m <= 2 ? 2 : m <= 5 ? 5 : 10) * pow;
|
||||
}
|
||||
|
||||
function niceTicks(lo: number, hi: number): number[] {
|
||||
const step = niceStep(hi - lo, 8);
|
||||
const out: number[] = [];
|
||||
for (let t = Math.ceil(lo / step) * step; t <= hi; t += step) out.push(Math.round(t * 1e6) / 1e6);
|
||||
return out;
|
||||
}
|
||||
|
||||
const fa = (n: number) => toFa(String(n).replace('-', '−'));
|
||||
Reference in New Issue
Block a user