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:
143
src/diagram/svg.tsx
Normal file
143
src/diagram/svg.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { ColorName } from './types';
|
||||
import { toFa } from '@/lib/format';
|
||||
|
||||
/**
|
||||
* رنگها و قطعههای مشترکِ همهی شکلها.
|
||||
*
|
||||
* رنگها معنا دارند و در همهی شکلها یکیاند: آبی شکلِ اصلی، قرمز زاویه و چیزی که
|
||||
* باید دیده شود، سبز جواب، نارنجی/بنفش سریهای دوم و سوم. مدل فقط نامِ رنگ را
|
||||
* میدهد، نه کدِ hex، تا شکلها همیشه با رنگهای اپ بخوانند.
|
||||
*/
|
||||
export const COLORS: Record<ColorName, string> = {
|
||||
blue: '#3F5BD8',
|
||||
red: '#E5484D',
|
||||
green: '#1F9D55',
|
||||
orange: '#EE8A12',
|
||||
purple: '#8B5CF6',
|
||||
teal: '#0E9F9F',
|
||||
pink: '#D9468F',
|
||||
gray: '#8A8FA3',
|
||||
black: '#26263D',
|
||||
yellow: '#F5B400',
|
||||
};
|
||||
|
||||
/** ترتیبِ رنگِ سریها وقتی مدل رنگ نداده (نمودارِ دوم، ستونِ سوم، …). */
|
||||
export const SERIES: ColorName[] = ['blue', 'red', 'green', 'orange', 'purple', 'teal', 'pink', 'yellow'];
|
||||
|
||||
export const INK = COLORS.black;
|
||||
export const SOFT = '#9EA3B8';
|
||||
export const GRID = '#E4E6F0';
|
||||
export const PAPER = '#FBFBFF';
|
||||
|
||||
export const color = (name: ColorName | undefined, fallback: string) => (name ? COLORS[name] : fallback);
|
||||
|
||||
/** عدد با رقمِ فارسی، منفیِ درست (−) و بدونِ صفرهای بیمعنای اعشار. */
|
||||
export function fa(n: number, digits = 2): string {
|
||||
if (!Number.isFinite(n)) return '';
|
||||
const r = Math.round(n * 10 ** digits) / 10 ** digits;
|
||||
const s = (Object.is(r, -0) ? 0 : r).toString();
|
||||
return toFa(s.replace('-', '−').replace('.', '٫'));
|
||||
}
|
||||
|
||||
/** حرفِ فارسی/عربی — نه رقم: ۰–۹ِ فارسی (U+06F0…) عمداً بیرون است. */
|
||||
export const PERSIAN_LETTER = /[\u0620-\u064A\u066E-\u06D3\u06FA-\u06FF]/;
|
||||
|
||||
/**
|
||||
* متنِ ریاضی را چپبهراست قفل میکند. رقمِ فارسی در الگوریتمِ جهتِ یونیکد «عددِ
|
||||
* عربی» است و علامتهای بینِ دو رقم را راستبهچپ میکند: «۳ × ۴ = ۱۲» به شکلِ
|
||||
* «۱۲ = ۴ × ۳» دیده میشد، حتی با dir=ltr. نشانهی نامرئیِ LRM بینِ نویسهها
|
||||
* همهی علامتها را چپبهراست نگه میدارد. فقط برای متنی که حرفِ فارسی ندارد.
|
||||
*/
|
||||
export function ltr(s: string): string {
|
||||
if (!s || PERSIAN_LETTER.test(s)) return s;
|
||||
return '\u200E' + [...s].join('\u200E') + '\u200E';
|
||||
}
|
||||
|
||||
/** رقمِ لاتین به فارسی، منفیِ درست (−)، و قفلِ چپبهراست برای متنِ ریاضیِ خالص. */
|
||||
export function mathText(s: string): string {
|
||||
const t = toFa(s.replace(/-/g, '−'));
|
||||
return ltr(t);
|
||||
}
|
||||
|
||||
/**
|
||||
* جهتِ متنِ SVG: متنِ ریاضیِ خالص با bidi-override چپبهراست (در Chrome نه LRM و نه
|
||||
* direction=ltr رقمِ فارسی را از برعکس شدن نجات نداد: «۳ × ۴ = ۱۲» → «۱۲ = ۴ × ۳»)،
|
||||
* و متنِ فارسی جهتش را از خودش میگیرد.
|
||||
*/
|
||||
export function bidi(children: ReactNode): { direction?: 'ltr'; style: { unicodeBidi: 'plaintext' | 'bidi-override'; direction?: 'ltr' } } {
|
||||
const math = typeof children === 'string' && !PERSIAN_LETTER.test(children);
|
||||
// bidi-override: نویسهها دقیقاً به ترتیبِ نوشتن، چپ به راست. برای ریاضیِ خالص درست
|
||||
// همان چیزی است که میخواهیم؛ برای متنِ فارسی هرگز، چون حروف را برعکس میکند.
|
||||
return math ? { direction: 'ltr', style: { unicodeBidi: 'bidi-override', direction: 'ltr' } } : { style: { unicodeBidi: 'plaintext' } };
|
||||
}
|
||||
|
||||
/** برچسب با هالهی سفید تا روی خطها خوانا بماند؛ جهتش را از متنِ خودش میگیرد. */
|
||||
export function Label({
|
||||
x, y, color: c = INK, size = 11, weight = 700, anchor = 'middle', children,
|
||||
}: { x: number; y: number; color?: string; size?: number; weight?: number; anchor?: 'start' | 'middle' | 'end'; children: ReactNode }) {
|
||||
return (
|
||||
<text x={x} y={y} fontSize={size} fontWeight={weight} fill={c} textAnchor={anchor}
|
||||
stroke={PAPER} strokeWidth={3.2} strokeLinejoin="round" paintOrder="stroke" {...bidi(children)}>
|
||||
{children}
|
||||
</text>
|
||||
);
|
||||
}
|
||||
|
||||
/** سرِ فلش؛ هر رنگ یک marker (SVG رنگِ marker را از خط به ارث نمیبرد). */
|
||||
export function Arrowheads({ ids }: { ids: string[] }) {
|
||||
return (
|
||||
<defs>
|
||||
{[...new Set(ids)].map((c) => (
|
||||
<marker key={c} id={arrowId(c)} viewBox="0 0 10 10" refX="8.5" refY="5" markerWidth="6.5" markerHeight="6.5" orient="auto-start-reverse">
|
||||
<path d="M0,0 L10,5 L0,10 z" fill={c} />
|
||||
</marker>
|
||||
))}
|
||||
</defs>
|
||||
);
|
||||
}
|
||||
export const arrowId = (hex: string) => `dg-ah-${hex.replace('#', '')}`;
|
||||
|
||||
/** کسرِ دوطبقه (صورت روی مخرج)؛ در کتابِ دبستان «/» ممیز است، پس کسر همیشه طبقهای کشیده میشود. */
|
||||
export function StackedFraction({ x, y, num, den, color: c = INK, size = 10 }: { x: number; y: number; num: string; den: string; color?: string; size?: number }) {
|
||||
const w = Math.max(num.length, den.length) * size * 0.62 + 4;
|
||||
return (
|
||||
<g>
|
||||
<text x={x} y={y - 2} fontSize={size} fontWeight={700} fill={c} textAnchor="middle">{num}</text>
|
||||
<line x1={x - w / 2} x2={x + w / 2} y1={y + 1} y2={y + 1} stroke={c} strokeWidth={1} />
|
||||
<text x={x} y={y + size + 2} fontSize={size} fontWeight={700} fill={c} textAnchor="middle">{den}</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
export function niceStep(span: number, maxTicks: number): number {
|
||||
const raw = span / Math.max(1, maxTicks);
|
||||
const pow = 10 ** Math.floor(Math.log10(raw || 1));
|
||||
const m = raw / pow;
|
||||
return (m <= 1 ? 1 : m <= 2 ? 2 : m <= 2.5 ? 2.5 : m <= 5 ? 5 : 10) * pow;
|
||||
}
|
||||
|
||||
export function ticksBetween(lo: number, hi: number, step: number): number[] {
|
||||
const out: number[] = [];
|
||||
for (let t = Math.ceil(lo / step - 1e-9) * step; t <= hi + 1e-9; t += step) out.push(Math.round(t * 1e9) / 1e9);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** بزرگترین مقسومعلیهِ مشترک، برای ساده کردنِ کسرِ برچسبها. */
|
||||
export function gcd(a: number, b: number): number {
|
||||
a = Math.abs(a); b = Math.abs(b);
|
||||
while (b) [a, b] = [b, a % b];
|
||||
return a || 1;
|
||||
}
|
||||
|
||||
/** برچسبِ مضربِ π برای محورِ مثلثاتی: π/2، π، 3π/2، −π، … */
|
||||
export function piLabel(t: number): string {
|
||||
const k = Math.round(t / (Math.PI / 2));
|
||||
if (k === 0) return '۰';
|
||||
const g = gcd(k, 2);
|
||||
const num = k / g, den = 2 / g;
|
||||
const sign = num < 0 ? '−' : '';
|
||||
const n = Math.abs(num);
|
||||
const head = n === 1 ? 'π' : `${toFa(n)}π`;
|
||||
return den === 1 ? `${sign}${head}` : `${sign}${head}/${toFa(den)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user