Files
MathApp_frontend/src/diagram/svg.tsx

166 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { createContext, 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'];
/** رنگِ هر ستون یا برشِ نمودار: اگر برچسب نامِ یک رنگ باشد («قرمز»، «مهره‌ی آبی (۳)») همان رنگ، وگرنه رنگ‌های
* پیش‌فرض به ترتیب، بدون رنگ‌هایی که برچسبی به نامشان هست؛ تا «قرمز» در چرخنده یا جدولِ فراوانی آبی کشیده نشود. */
const COLOR_WORDS: [string, ColorName][] = [
['قرمز', 'red'], ['سرخ', 'red'], ['آبی', 'blue'], ['سبز', 'green'], ['نارنجی', 'orange'], ['بنفش', 'purple'],
['صورتی', 'pink'], ['زرد', 'yellow'], ['خاکستری', 'gray'], ['مشکی', 'black'], ['سیاه', 'black'],
];
const namedColor = (label: string): ColorName | undefined =>
COLOR_WORDS.find(([w]) => new RegExp(`(^|[\\s‌(«])${w}($|[\\s‌)»])`).test(label))?.[1];
export function seriesColors(labels: string[], n: number): string[] {
const named = Array.from({ length: n }, (_, i) => namedColor(labels[i] ?? ''));
const free = SERIES.filter((c) => !named.includes(c));
let k = 0;
return named.map((c) => COLORS[c ?? free[k++ % free.length]]);
}
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)}`;
}
/**
* زیرِ شکلِ سوالِ آزمون، شمارشِ خودکار («۲ تکه از ۵ تکه»، «۶ خانه از ۱۲ خانه»، «۶ × ۴ = ۲۴»، «۹ تا»)
* جوابِ سوال را لو می‌داد؛ آزمون این را روشن می‌کند و شکل فقط خودِ داده را نشان می‌دهد.
* در درس و جوابِ دستیار شمارش می‌ماند چون آنجا توضیح است، نه سوال.
*/
export const HideCounts = createContext(false);