166 lines
8.5 KiB
TypeScript
166 lines
8.5 KiB
TypeScript
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);
|