diff --git a/src/api/types.ts b/src/api/types.ts index e10e118..beabb30 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -219,25 +219,10 @@ export interface ConversationMessage { diagram?: Diagram | null; } -/* ─── شکلِ پاسخِ دستیار (بک‌اند: rag/services/diagram.py) ─────────────────── */ +/* ─── شکلِ پاسخِ دستیار: شِمایش در src/diagram/types.ts ─────────────────── */ -export type DiagramRef = string | [number, number]; -export type DiagramPointDef = [number, number] | { polar: [number, number]; from?: DiagramRef }; - -export type DiagramItem = - | { type: 'circle'; center: DiagramRef; r: number; label?: string } - | { type: 'segment' | 'arrow'; from: DiagramRef; to: DiagramRef; label?: string } - | { type: 'line'; through: [DiagramRef, DiagramRef]; label?: string } - | { type: 'angle' | 'rightAngle'; vertex: DiagramRef; from: DiagramRef; to: DiagramRef; label?: string } - | { type: 'polygon'; points: DiagramRef[]; label?: string } - | { type: 'point'; point: DiagramRef; label?: string } - | { type: 'function'; expr: string; from: number; to: number; label?: string } - | { type: 'text'; at: [number, number]; text: string }; - -export type Diagram = - | { kind: 'plane'; title: string; axes: boolean; points: Record; items: DiagramItem[] } - | { kind: 'numberline'; title: string; min: number; max: number; points: { at: number; label: string }[]; jumps: { from: number; to: number; label: string }[] } - | { kind: 'fraction'; title: string; shape: 'circle' | 'rect'; parts: number; shaded: number }; +import type { DiagramSpec } from '@/diagram/types'; +export type Diagram = DiagramSpec; export interface NotificationItem { id: number; diff --git a/src/components/Diagram.tsx b/src/components/Diagram.tsx deleted file mode 100644 index 5be2508..0000000 --- a/src/components/Diagram.tsx +++ /dev/null @@ -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 ; - if (spec.kind === 'fraction') return ; - if (spec.kind === 'plane') return ; - } catch { - /* شکلِ خراب نباید پاسخ را خراب کند */ - } - return null; - }, [spec]); - if (!body) return null; - return ( - // dir=ltr: محور و مختصات چپ‌به‌راست‌اند؛ در زمینه‌ی rtl «−۱» به «۱−» برمی‌گشت. - // برچسب‌های فارسی با unicode-bidi: plaintext جهتِ خودشان را می‌گیرند. -
- {body} - {spec.title &&
{spec.title}
} -
- ); -} - -export const Diagram = memo(DiagramBase); - -/* ─── صفحه‌ی مختصات / هندسه ───────────────────────────────────────────────── */ - -function Plane({ spec }: { spec: Extract }) { - // نقطه‌ها: polar نسبت به مبدأ یا نقطه‌ی from، با حلِ ارجاعِ زنجیره‌ای. - const named = new Map(); - const resolving = new Set(); - 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; 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( - - {ticks.map((t) => )} - {yticks.map((t) => )} - , - - - - , - - {ticks.filter((t) => t !== 0).map((t) => {fa(t)})} - {yticks.filter((t) => t !== 0).map((t) => {fa(t)})} - , - ); - } - - 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(); - } 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( - , - ); - 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(); - } 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( 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( 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( - 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(); - } - } else if (item.type === 'text') { - const [tx, ty] = P(item.at); - els.push(); - } - } - - 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(); - if (item.label) { - const last = clipped[Math.floor(clipped.length * 0.85)] ?? clipped[clipped.length - 1]; - els.push(); - } - } - - // نقطه‌ها آخر از همه، روی بقیه - const pointLabels = new Map(); - 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(); - if (label) els.push(); - }; - 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 ( - - - {els} - - ); -} - -/* ─── محورِ اعداد ─────────────────────────────────────────────────────────── */ - -function NumberLine({ spec }: { spec: Extract }) { - 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 ( - - - - {ticks.map((t) => ( - - - - {fa(t)} - - - ))} - {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 ( - - - {j.label && } - - ); - })} - {spec.points.map((p, i) => ( - - 1 ? ACCENT : MAIN} stroke="#fff" strokeWidth={1.5} /> - {p.label && !/^[-−]?[\d۰-۹.]+$/.test(p.label) && } - - ))} - - ); -} - -/* ─── کسر ─────────────────────────────────────────────────────────────────── */ - -function Fraction({ spec }: { spec: Extract }) { - const { parts, shaded } = spec; - const fill = (i: number) => (i < shaded ? '#FFC63D' : '#fff'); - if (spec.shape === 'rect') { - const w = (W - 2 * PAD) / parts; - return ( - - {Array.from({ length: parts }, (_, i) => ( - - ))} - {toFa(shaded)} از {toFa(parts)} - - ); - } - const cx = W / 2, cy = 82, r = 64; - return ( - - {parts === 1 ? ( - - ) : ( - 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 ( - - ); - }) - )} - {toFa(shaded)} از {toFa(parts)} تکه - - ); -} - -/* ─── کمکی‌ها ─────────────────────────────────────────────────────────────── */ - -function Markers() { - const head = (id: string, color: string, start = false) => ( - - - - ); - return ( - - {head('dg-arrow', ACCENT)} - {head('dg-arrow-soft', SOFT)} - {head('dg-arrow-ink', INK)} - {head('dg-arrow-ink-start', INK, true)} - - ); -} - -function Label({ x, y, color, bold = false, children }: { x: number; y: number; color: string; bold?: boolean; children: ReactNode }) { - return ( - - {children} - - ); -} - -/** برچسبِ ضلع، وسطِ پاره‌خط و به سمتِ بیرونِ شکل. */ -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 ; -} - -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('-', '−')); diff --git a/src/dev/DiagramGallery.tsx b/src/dev/DiagramGallery.tsx new file mode 100644 index 0000000..c94b1c9 --- /dev/null +++ b/src/dev/DiagramGallery.tsx @@ -0,0 +1,41 @@ +import { Diagram } from '@/diagram'; +import type { DiagramSpec } from '@/diagram/types'; +import { SAMPLES } from './diagramSamples'; +import { MathText } from '@/lib/MathText'; + +const TEXTS = [ + '۵) پس وقتی از ۲ سه واحد به چپ برویم، به عدد -۱ می‌رسیم.', + '✅ جواب: رأس سهمی در (1, -1) است.', + '۴) ۲ - ۳ = -۱.', + 'مجموع زاویه‌های داخلی = (5 - 2) × 180 = 3 × 180 = 540 درجه', + 'بازه‌ی [-2, 3) یعنی از −۲ تا ۳، خودِ ۳ نه.', + 'sin 30° = 0.5 و cos 30° ≈ 0.87 است.', + 'در کتاب ۲/۵ یعنی دو و نیم (اختیاری).', +]; + +/** + * گالریِ شکل‌ها، فقط در حالتِ توسعه: http://localhost:5173/?gallery + * برای وارسیِ چشمی بعد از هر تغییر در src/diagram. اگر window.__GALLERY__ ست شده + * باشد (تستِ خودکار با خروجیِ واقعیِ مدل)، همان‌ها را نشان می‌دهد. + */ +export function DiagramGallery() { + const injected = (window as unknown as { __GALLERY__?: { title: string; spec: DiagramSpec }[] }).__GALLERY__; + const items = injected ?? SAMPLES; + return ( +
+ {!injected && ( +
+ {TEXTS.map((t) =>

)} +
+ )} +
+ {items.map((it, i) => ( +
+

{i + 1}. {it.title}

+ +
+ ))} +
+
+ ); +} diff --git a/src/dev/diagramSamples.ts b/src/dev/diagramSamples.ts new file mode 100644 index 0000000..d4dd88e --- /dev/null +++ b/src/dev/diagramSamples.ts @@ -0,0 +1,226 @@ +import type { DiagramSpec } from '@/diagram/types'; + +/** نمونه‌های دست‌نویس از هر نوع شکل، از روی مسئله‌های واقعیِ کتاب‌ها. فقط برای گالری. */ +export const SAMPLES: { title: string; spec: DiagramSpec }[] = [ + { + title: 'یازدهم — سهمی با ریشه، رأس، محورِ تقارن و عرض از مبدأ', + spec: { kind: 'plane', title: 'y = x² − 2x − 3', axes: true, points: {}, items: [ + { type: 'function', expr: 'x^2-2x-3', from: -2.5, to: 4.5, label: 'y = x² − 2x − 3', marks: ['roots', 'vertex', 'axis', 'yIntercept'] }, + ] }, + }, + { + title: 'دهم — دایره‌ی مثلثاتی، سینوس و کسینوسِ ۳۰°', + spec: { kind: 'plane', title: 'زاویه‌ی ۳۰ درجه', axes: true, points: { O: [0, 0], A: [1, 0], P: { polar: [1, 30] }, H: { foot: ['P', 'O', 'A'] } }, items: [ + { type: 'circle', center: 'O', r: 1 }, + { type: 'segment', from: 'O', to: 'P' }, + { type: 'segment', from: 'P', to: 'H', dashed: true, color: 'green', label: 'sin 30° = 0.5' }, + { type: 'segment', from: 'O', to: 'H', color: 'orange', label: 'cos 30°' }, + { type: 'angle', vertex: 'O', from: 'A', to: 'P', label: '30°' }, + { type: 'rightAngle', vertex: 'H', from: 'O', to: 'P' }, + ] }, + }, + { + title: 'نهم — مثلثِ قائم‌الزاویه، فیثاغورس', + spec: { kind: 'plane', title: 'وتر = ۵', axes: false, points: { A: [0, 0], B: [4, 0], C: [0, 3] }, items: [ + { type: 'polygon', points: ['A', 'B', 'C'] }, + { type: 'rightAngle', vertex: 'A', from: 'B', to: 'C' }, + { type: 'segment', from: 'A', to: 'B', label: '4' }, + { type: 'segment', from: 'A', to: 'C', label: '3' }, + { type: 'segment', from: 'B', to: 'C', label: '?', color: 'red' }, + ] }, + }, + { + title: 'هشتم — خطوطِ موازی و مورب، زاویه‌های مساوی', + spec: { kind: 'plane', title: 'a ∥ b و d مورب', axes: false, points: { _a1: [0, 2], _a2: [6, 2], _b1: [0, 0], _b2: [6, 0], _d1: [1.2, -1], _d2: [4.8, 3], P: { intersect: [['_a1', '_a2'], ['_d1', '_d2']] }, Q: { intersect: [['_b1', '_b2'], ['_d1', '_d2']] } }, items: [ + { type: 'line', through: ['_a1', '_a2'], label: 'a' }, + { type: 'line', through: ['_b1', '_b2'], label: 'b' }, + { type: 'line', through: ['_d1', '_d2'], color: 'purple', label: 'd' }, + { type: 'angle', vertex: 'P', from: '_a2', to: '_d2', label: 'Â1', arcs: 1 }, + { type: 'angle', vertex: 'Q', from: '_b2', to: '_d2', label: 'B̂1', arcs: 1 }, + ] }, + }, + { + title: 'هشتم — زاویه‌ی محاطی و مرکزیِ روبه‌روی یک کمان', + spec: { kind: 'plane', title: 'زاویه‌ی محاطی نصفِ مرکزی است', axes: false, points: { O: [0, 0], A: { polar: [2, 200] }, B: { polar: [2, -20] }, C: { polar: [2, 100] } }, items: [ + { type: 'circle', center: 'O', r: 2 }, + { type: 'arc', center: 'O', r: 2, start: 200, end: 340, label: 'AB' }, + { type: 'segment', from: 'C', to: 'A' }, { type: 'segment', from: 'C', to: 'B' }, + { type: 'segment', from: 'O', to: 'A', color: 'blue' }, { type: 'segment', from: 'O', to: 'B', color: 'blue' }, + { type: 'angle', vertex: 'C', from: 'A', to: 'B', label: '70°' }, + { type: 'angle', vertex: 'O', from: 'A', to: 'B', label: '140°', color: 'blue' }, + ] }, + }, + { + title: 'نهم — متوازی‌الاضلاع و وسطِ ضلع‌ها، ضلع‌های مساوی', + spec: { kind: 'plane', title: 'M و N وسطِ ضلع‌ها', axes: false, points: { A: [0, 0], B: [4, 0], C: [5, 2.5], D: [1, 2.5], M: { mid: ['A', 'B'] }, N: { mid: ['C', 'D'] } }, items: [ + { type: 'polygon', points: ['A', 'B', 'C', 'D'] }, + { type: 'segment', from: 'A', to: 'M', ticks: 1 }, { type: 'segment', from: 'M', to: 'B', ticks: 1 }, + { type: 'segment', from: 'D', to: 'N', ticks: 2 }, { type: 'segment', from: 'N', to: 'C', ticks: 2 }, + { type: 'segment', from: 'M', to: 'N', dashed: true, color: 'purple' }, + ] }, + }, + { + title: 'هفتم — دورانِ ۹۰° مثلث حولِ C در جهتِ عقربه‌ها، روی شبکه', + spec: { kind: 'plane', title: 'دورانِ ۹۰°', axes: false, grid: true, points: { A: [1, 1], B: [4, 1], C: [1, 3] }, items: [ + { type: 'polygon', points: ['A', 'B', 'C'] }, + { type: 'image', of: ['A', 'B', 'C'], transform: { rotate: { center: 'C', deg: -90 } } }, + ] }, + }, + { + title: 'هشتم — بردار و جمعِ بردارها روی شبکه', + spec: { kind: 'plane', title: 'AB + BC = AC', axes: false, grid: true, points: { A: [0, 0], B: [3, 1], C: [4, 4] }, items: [ + { type: 'vector', from: 'A', to: 'B', label: 'a' }, + { type: 'vector', from: 'B', to: 'C', label: 'b', color: 'blue' }, + { type: 'vector', from: 'A', to: 'C', label: 'a + b', color: 'green' }, + ] }, + }, + { + title: 'نهم — دو خط و محلِ برخورد', + spec: { kind: 'plane', title: 'y = 2x − 1 و y = −x + 5', axes: true, points: { P: { intersect: [[[0, -1], [1, 1]], [[0, 5], [1, 4]]] } }, items: [ + { type: 'function', expr: '2x-1', from: -1, to: 4, label: 'y = 2x − 1' }, + { type: 'function', expr: '-x+5', from: -1, to: 6, label: 'y = −x + 5' }, + { type: 'point', point: 'P', label: 'P (2, 3)' }, + ] }, + }, + { + title: 'یازدهم — نمودارِ سینوس با مضرب‌های π', + spec: { kind: 'plane', title: 'y = sin x', axes: true, trig: true, points: {}, items: [ + { type: 'function', expr: 'sin(x)', from: -6.2832, to: 6.2832, label: 'y = sin x', marks: ['roots', 'extrema'] }, + { type: 'hline', y: 0.5, color: 'orange', label: 'y = ½' }, + ] }, + }, + { + title: 'یازدهم — تابعِ چندضابطه‌ای و حد (نقطه‌ی توخالی)', + spec: { kind: 'plane', title: 'حدِ چپ و راست در x = 1', axes: true, points: {}, items: [ + { type: 'piecewise', pieces: [{ expr: 'x+1', from: -2, to: 1, closedFrom: true, closedTo: false }, { expr: '-x+4', from: 1, to: 4, closedFrom: true, closedTo: true }] }, + { type: 'vline', x: 1, label: 'x = 1' }, + ] }, + }, + { + title: 'دوازدهم — خطِ مماس بر نمودار', + spec: { kind: 'plane', title: 'مماس بر y = x² در x = 1', axes: true, points: {}, items: [ + { type: 'function', expr: 'x^2', from: -2, to: 2.5, label: 'y = x²' }, + { type: 'tangent', expr: 'x^2', at: 1, label: 'y = 2x − 1' }, + ] }, + }, + { + title: 'دوازدهم — مجانب‌ها: y = 1/(x − 2) + 1', + spec: { kind: 'plane', title: 'مجانبِ قائم و افقی', axes: true, xRange: [-2, 6], yRange: [-4, 6], points: {}, items: [ + { type: 'function', expr: '1/(x-2)+1', from: -2, to: 6, label: 'y = 1/(x−2) + 1' }, + { type: 'vline', x: 2, label: 'x = 2', color: 'red' }, + { type: 'hline', y: 1, label: 'y = 1', color: 'red' }, + ] }, + }, + { + title: 'دوازدهم — اکسترمم‌های نسبیِ تابعِ درجه‌ی سوم', + spec: { kind: 'plane', title: 'y = x³ − 3x', axes: true, points: {}, items: [ + { type: 'function', expr: 'x^3-3x', from: -2.3, to: 2.3, label: 'y = x³ − 3x', marks: ['extrema', 'roots'] }, + ] }, + }, + { + title: 'یازدهم — تابع و وارونش، قرینه نسبت به y = x', + spec: { kind: 'plane', title: 'f و f⁻¹', axes: true, xRange: [-1, 8], yRange: [-1, 8], points: {}, items: [ + { type: 'function', expr: '2^x', from: -1, to: 3, label: 'y = 2ˣ' }, + { type: 'function', expr: 'log(x)/log(2)', from: 0.3, to: 8, label: 'y = log₂x' }, + { type: 'function', expr: 'x', from: -1, to: 8, dashed: true, color: 'gray', label: 'y = x' }, + ] }, + }, + { + title: 'دوازدهم — مساحتِ زیرِ نمودار', + spec: { kind: 'plane', title: 'ناحیه‌ی بینِ y = √x و محورِ x', axes: true, points: {}, items: [ + { type: 'area', expr: 'sqrt(x)', from: 0, to: 4, color: 'green', label: 'S' }, + { type: 'function', expr: 'sqrt(x)', from: 0, to: 5, label: 'y = √x', color: 'green' }, + ] }, + }, + { + title: 'دهم — ناحیه‌ی جوابِ نامعادله', + spec: { kind: 'plane', title: 'y > 2x − 1', axes: true, xRange: [-3, 3], yRange: [-4, 4], points: {}, items: [ + { type: 'region', expr: '2x-1', side: 'above', strict: true, label: 'y > 2x − 1' }, + ] }, + }, + { + title: 'هشتم — محورِ اعداد: از ۲ سه واحد به چپ', + spec: { kind: 'numberline', title: '۲ − ۳ = −۱', min: -4, max: 4, points: [{ at: 2, label: 'شروع' }, { at: -1, label: 'پایان' }], jumps: [{ from: 2, to: -1, label: '۳ واحد به چپ' }] }, + }, + { + title: 'دهم — بازه‌ها روی محور', + spec: { kind: 'numberline', title: '[−۲، ۳) و (۱، +∞)', min: -4, max: 5, points: [], jumps: [], intervals: [ + { from: -2, to: 3, closedFrom: true, closedTo: false, label: '[−2, 3)' }, + { from: 1, to: null, closedFrom: false, label: '(1, +∞)' }, + ] }, + }, + { + title: 'ششم — کسرها روی محور', + spec: { kind: 'numberline', title: 'سه‌چهارم', min: 0, max: 2, step: 0.25, fractions: true, points: [{ at: 0.75, label: 'سه‌چهارم' }], jumps: [] }, + }, + { + title: 'ششم — کسر روی دایره', + spec: { kind: 'fraction', title: 'سه‌هشتم', shape: 'circle', parts: 8, shaded: 3 }, + }, + { + title: 'ششم — ۲۰٪ از ۲۵ خانه', + spec: { kind: 'grid', title: '۲۰ درصد', rows: 5, cols: 5, shaded: 5 }, + }, + { + title: 'سوم — مدلِ ضرب: ۳ گروهِ ۴تایی', + spec: { kind: 'groups', title: '۳ × ۴', groups: 3, each: 4, item: 'circle' }, + }, + { + title: 'سوم — ساعت ۳ و ۲۵ دقیقه', + spec: { kind: 'clock', title: 'ساعت ۳:۲۵', hour: 3, minute: 25 }, + }, + { + title: 'سوم — نمودارِ ستونیِ آب‌میوه‌ها', + spec: { kind: 'bar', title: 'آب‌میوه‌ی موردِ علاقه', labels: ['پرتقال', 'سیب', 'انگور', 'هلو'], values: [8, 5, 3, 4], unit: 'نفر' }, + }, + { + title: 'هشتم — نمودارِ دایره‌ای', + spec: { kind: 'pie', title: 'ورزشِ موردِ علاقه', labels: ['فوتبال', 'والیبال', 'شنا', 'بقیه'], values: [12, 6, 4, 2] }, + }, + { + title: 'دهم — دمای زمین بر حسبِ عمق (خط‌شکسته)', + spec: { kind: 'linechart', title: 'دما (°C)', labels: ['1', '2', '3', '4', '5', '6'], values: [55, 90, 125, 160, 195, 230], unit: '°C' }, + }, + { + title: 'دهم — جدولِ تعیینِ علامت', + spec: { kind: 'table', title: 'y = (x + 1)(x − 2)', headers: ['x', '−∞', '−1', '', '2', '+∞'], rows: [['y', '+', '0', '−', '0', '+']] }, + }, + { + title: 'دهم — نمودارِ ون: A ∩ B', + spec: { kind: 'venn', title: 'A = {1,2,3,4} و B = {3,4,5}', sets: ['A', 'B'], regions: { A: ['1', '2'], AB: ['3', '4'], B: ['5'], out: ['6'] }, shade: ['AB'] }, + }, + { + title: 'دهم — ون با سه مجموعه', + spec: { kind: 'venn', title: 'A − (B ∪ C)', sets: ['A', 'B', 'C'], regions: { A: ['1'], B: ['2'], C: ['3'], AB: ['4'], ABC: ['5'] }, shade: ['A'] }, + }, + { + title: 'دهم — نمودارِ پیکانیِ تابع', + spec: { kind: 'mapping', title: 'f(x) = 3(x − 1)²', left: ['0', '0.5', '1'], right: ['3', '0.75', '0'], arrows: [[0, 0], [1, 1], [2, 2]], leftLabel: 'دامنه', rightLabel: 'برد' }, + }, + { + title: 'نهم — نمودارِ درختیِ دو بار پرتابِ سکه', + spec: { kind: 'tree', title: '۴ حالت', root: { label: 'شروع', children: [ + { label: 'رو', p: '1/2', children: [{ label: 'رو', p: '1/2' }, { label: 'پشت', p: '1/2' }] }, + { label: 'پشت', p: '1/2', children: [{ label: 'رو', p: '1/2' }, { label: 'پشت', p: '1/2' }] }, + ] } }, + }, + { title: 'هفتم — مکعب‌مستطیل', spec: { kind: 'solid', title: 'حجم = ۵ × ۳ × ۴', shape: 'cuboid', dims: { a: 5, b: 3, c: 4 }, labels: { a: '5 cm', b: '3 cm', c: '4 cm' } } }, + { title: 'هفتم — مکعب', spec: { kind: 'solid', title: 'مکعب', shape: 'cube', dims: { a: 3 }, labels: { a: 'a' } } }, + { title: 'هفتم — منشورِ سه‌پهلو', spec: { kind: 'solid', title: 'منشورِ مثلثی', shape: 'prism', dims: { n: 3, r: 1.2, h: 2 }, labels: { h: 'h' } } }, + { title: 'نهم — منشورِ شش‌پهلو', spec: { kind: 'solid', title: 'منشورِ شش‌ضلعی', shape: 'prism', dims: { n: 6, r: 1.2, h: 1.6 }, labels: { h: '8 cm' } } }, + { title: 'نهم — هرمِ مربع‌القاعده با ارتفاع', spec: { kind: 'solid', title: 'هرم', shape: 'pyramid', dims: { n: 4, r: 1.3, h: 2 }, labels: { h: 'h', a: 'a' } } }, + { title: 'نهم — هرمِ پنج‌پهلو', spec: { kind: 'solid', title: 'قاعده‌ی ABCDE', shape: 'pyramid', dims: { n: 5, r: 1.3, h: 2 }, labels: { h: 'OH' } } }, + { title: 'نهم — استوانه', spec: { kind: 'solid', title: 'لیوانِ استوانه‌ای', shape: 'cylinder', dims: { r: 6, h: 10 }, labels: { r: '6 cm', h: 'h' } } }, + { title: 'نهم — مخروط', spec: { kind: 'solid', title: 'ظرفِ مخروطی', shape: 'cone', dims: { r: 4, h: 12 }, labels: { r: '4 cm', h: '12 cm' } } }, + { title: 'نهم — کره', spec: { kind: 'solid', title: 'کره', shape: 'sphere', dims: { r: 1 }, labels: { r: 'r' } } }, + { title: 'نهم — نیم‌کره', spec: { kind: 'solid', title: 'پیمانه‌ی نیم‌کره', shape: 'hemisphere', dims: { r: 12 }, labels: { r: '12 cm' } } }, + { title: 'ششم — مقایسه‌ی یک‌پنجم و نُه‌دهم', spec: { kind: 'fraction', title: 'کدام بزرگ‌تر است؟', shape: 'rect', parts: 5, shaded: 1, compare: [{ parts: 5, shaded: 1, label: 'یک‌پنجم' }, { parts: 10, shaded: 9, label: 'نُه‌دهم' }] } }, + { title: 'ششم — دو و هفت‌دهم', spec: { kind: 'fraction', title: '۲٫۷', shape: 'circle', parts: 10, shaded: 7, wholes: 2 } }, + { title: 'دهم — دایره و خطِ مماس از معادله', spec: { kind: 'plane', title: 'فاصله‌ی مرکز تا خط', axes: true, points: { O: [-1, 2] }, items: [ + { type: 'line', eq: [3, 5, 4], label: '3x + 5y = 4' }, { type: 'circle', center: 'O', r: 0.5145, color: 'green' }, + ] } }, + { title: 'هشتم — زاویه‌ی خارجیِ پنج‌ضلعی با امتدادِ ضلع', spec: { kind: 'plane', title: 'زاویه‌ی خارجی = ۷۲°', axes: false, points: { A: { polar: [2, 234] }, B: { polar: [2, 306] }, C: { polar: [2, 18] }, D: { polar: [2, 90] }, E: { polar: [2, 162] }, X: { along: ['A', 'B', 1.6] } }, items: [ + { type: 'polygon', points: ['A', 'B', 'C', 'D', 'E'] }, { type: 'segment', from: 'B', to: 'X', dashed: true }, { type: 'angle', vertex: 'B', from: 'X', to: 'C', label: '72°' }, + ] } }, + { title: 'سوم — ضرب روی محورِ اعداد (تیک‌های زیاد)', spec: { kind: 'numberline', title: '۲ × ۱۱', min: 0, max: 25, points: [{ at: 11, label: '' }, { at: 22, label: '' }], jumps: [{ from: 0, to: 11, label: '۱۱' }, { from: 11, to: 22, label: '۱۱' }] } }, +]; diff --git a/src/diagram/charts.tsx b/src/diagram/charts.tsx new file mode 100644 index 0000000..a9bf954 --- /dev/null +++ b/src/diagram/charts.tsx @@ -0,0 +1,99 @@ +import type { ChartSpec } from './types'; +import { COLORS, GRID, INK, Label, SERIES, SOFT, bidi, fa, mathText, niceStep, ticksBetween } from './svg'; + +const W = 330; + +/** نمودارِ ستونی، خط‌شکسته و دایره‌ای. درصدِ دایره‌ای را کد از روی مقدارها حساب می‌کند. */ +export function Chart({ spec }: { spec: ChartSpec }) { + const n = Math.min(spec.labels.length, spec.values.length); + if (!n) return null; + const labels = spec.labels.slice(0, n), values = spec.values.slice(0, n); + return spec.kind === 'pie' ? : ; +} + +function Bars({ kind, labels, values, unit }: { kind: 'bar' | 'linechart'; labels: string[]; values: number[]; unit?: string }) { + const H = 230, left = 38, right = 12, top = 22, bottom = 44; + const lo = Math.min(0, ...values), hi = Math.max(0, ...values); + const step = niceStep(hi - lo || 1, 5); + const y0 = Math.floor(lo / step) * step, y1 = Math.ceil(hi / step) * step || step; + const plotW = W - left - right, plotH = H - top - bottom; + const Y = (v: number) => top + ((y1 - v) / (y1 - y0)) * plotH; + const slot = plotW / labels.length; + const X = (i: number) => left + slot * (i + 0.5); + const barW = Math.min(38, slot * 0.62); + const long = labels.some((l) => l.length > 6); + + return ( + + {ticksBetween(y0, y1, step).map((t) => ( + + + {fa(t)} + + ))} + {unit && {mathText(unit)}} + {kind === 'bar' + ? values.map((v, i) => { + const c = COLORS[SERIES[i % SERIES.length]]; + return ( + + + + + ); + }) + : ( + <> + `${i ? 'L' : 'M'}${X(i)},${Y(v)}`).join(' ')} fill="none" stroke={COLORS.blue} strokeWidth={2.6} strokeLinejoin="round" /> + {values.map((v, i) => ( + + + + + ))} + + )} + {labels.map((l, i) => ( + {mathText(l)} + ))} + + ); +} + +function Pie({ labels, values }: { labels: string[]; values: number[] }) { + const total = values.reduce((a, v) => a + Math.max(0, v), 0); + if (total <= 0) return null; + const cx = 105, cy = 105, r = 88; + let a = -Math.PI / 2; + const slices = values.map((v, i) => { + const frac = Math.max(0, v) / total, a0 = a, a1 = a + frac * 2 * Math.PI; + a = a1; + return { i, frac, a0, a1, c: COLORS[SERIES[i % SERIES.length]] }; + }); + const H = Math.max(215, 24 + labels.length * 22); + return ( + + {slices.map((s) => { + if (s.frac >= 0.9999) return ; + const large = s.a1 - s.a0 > Math.PI ? 1 : 0; + const p0 = [cx + r * Math.cos(s.a0), cy + r * Math.sin(s.a0)], p1 = [cx + r * Math.cos(s.a1), cy + r * Math.sin(s.a1)]; + return ; + })} + {slices.filter((s) => s.frac >= 0.06).map((s) => { + const m = (s.a0 + s.a1) / 2; + return ( + + {fa(s.frac * 100, 1)}٪ + + ); + })} + {labels.map((l, i) => ( + + + {`${l} (${fa(values[i])})`} + + ))} + + ); +} diff --git a/src/lib/mathExpr.ts b/src/diagram/expr.ts similarity index 58% rename from src/lib/mathExpr.ts rename to src/diagram/expr.ts index 2ef596d..3617b60 100644 --- a/src/lib/mathExpr.ts +++ b/src/diagram/expr.ts @@ -19,7 +19,7 @@ const FUNCS: Record number> = { }; function tokenize(src: string): string[] { - const s = src.replace(/\s+/g, '').replace(/[−–]/g, '-').replace(/[×·]/g, '*').replace(/÷/g, '/').replace(/²/g, '^2').replace(/³/g, '^3').replace(/π/g, 'pi'); + const s = src.replace(/\s+/g, '').replace(/\*\*/g, '^').replace(/[−–]/g, '-').replace(/[×·]/g, '*').replace(/÷/g, '/').replace(/²/g, '^2').replace(/³/g, '^3').replace(/π/g, 'pi'); const out: string[] = []; let i = 0; while (i < s.length) { @@ -108,3 +108,72 @@ export function parseExpr(src: string): (x: number) => number { }; return (x) => ev(tree, x); } + +/* ─── تحلیلِ عددی: ریشه، اکسترمم، شیب ─────────────────────────────────────── + * همه با دو برابر کردنِ دقت (bisection) تا ۱e-10؛ برای نمایش به دو رقمِ اعشار گرد + * می‌شوند. ریشه‌ی مضاعف (مثلِ x² در صفر) علامت عوض نمی‌کند، پس جدا از روی + * اکسترممی که مقدارش صفر است پیدا می‌شود. + */ + +type F = (x: number) => number; + +function bisect(g: F, a: number, b: number): number { + let ga = g(a); + for (let i = 0; i < 80; i++) { + const m = (a + b) / 2, gm = g(m); + if (!Number.isFinite(gm)) return m; + if (Math.sign(gm) === Math.sign(ga)) { a = m; ga = gm; } else b = m; + if (b - a < 1e-11) break; + } + return (a + b) / 2; +} + +/** مشتقِ عددی (تفاضلِ مرکزی). */ +export function derivative(f: F, x: number): number { + const h = 1e-5 * Math.max(1, Math.abs(x)); + return (f(x + h) - f(x - h)) / (2 * h); +} + +const snap = (x: number) => { + const r = Math.round(x); + if (Math.abs(x - r) < 1e-7) return r; + const r2 = Math.round(x * 1e6) / 1e6; + return Math.abs(x - r2) < 1e-9 ? r2 : x; +}; + +export function extrema(f: F, from: number, to: number): { x: number; y: number; kind: 'max' | 'min' }[] { + const n = 600, out: { x: number; y: number; kind: 'max' | 'min' }[] = []; + const d = (x: number) => derivative(f, x); + let px = from, pd = d(px); + for (let i = 1; i <= n; i++) { + const x = from + ((to - from) * i) / n, dx = d(x); + if (Number.isFinite(pd) && Number.isFinite(dx) && pd !== 0 && Math.sign(dx) !== Math.sign(pd)) { + const xe = snap(bisect(d, px, x)); + const y = f(xe); + // لبه‌ی مجانب (1/x) علامتِ مشتق را بی‌اکسترمم عوض نمی‌کند: تابع باید پیوسته باشد. + if (Number.isFinite(y) && Math.abs(f(px) - f(x)) < 1e3 * (Math.abs(y) + 1)) out.push({ x: xe, y: snap(y), kind: pd > 0 ? 'max' : 'min' }); + } + px = x; pd = dx; + } + return out; +} + +export function roots(f: F, from: number, to: number): number[] { + const n = 800, out: number[] = []; + let px = from, py = f(px); + if (Math.abs(py) < 1e-12) out.push(from); + for (let i = 1; i <= n; i++) { + const x = from + ((to - from) * i) / n, y = f(x); + if (Math.abs(y) < 1e-12) out.push(x); + else if (Number.isFinite(py) && Number.isFinite(y) && py !== 0 && Math.sign(y) !== Math.sign(py)) { + const r = bisect(f, px, x); + // پرشِ علامت بدونِ گذر از صفر (مجانب) ریشه نیست. + if (Math.abs(f(r)) < 1e-6) out.push(r); + } + px = x; py = y; + } + for (const e of extrema(f, from, to)) if (Math.abs(e.y) < 1e-9) out.push(e.x); + const uniq: number[] = []; + for (const r of out.map(snap).sort((a, b) => a - b)) if (!uniq.length || Math.abs(r - uniq[uniq.length - 1]) > 1e-6) uniq.push(r); + return uniq; +} diff --git a/src/diagram/index.tsx b/src/diagram/index.tsx new file mode 100644 index 0000000..25a5ec2 --- /dev/null +++ b/src/diagram/index.tsx @@ -0,0 +1,58 @@ +import { Component, memo, type ReactNode } from 'react'; +import type { DiagramSpec } from './types'; +import { Plane } from './plane'; +import { NumberLine } from './numberline'; +import { Solid } from './solid'; +import { Chart } from './charts'; +import { Mapping, Tree, Venn } from './sets'; +import { Clock, Fraction, Grid, Groups, Table } from './misc'; +import { PAPER } from './svg'; +import { MathText } from '@/lib/MathText'; + +export type { DiagramSpec } from './types'; + +/** + * شکلِ جوابِ دستیار. توصیف از بک‌اند می‌آید (rag/services/diagram.py، اعتبارسنجی‌شده) + * و اینجا فقط به عنصرِ React/SVG تبدیل می‌شود؛ هیچ HTMLی از مدل وارد صفحه نمی‌شود. + * + * هر خطایی در رسم (شکلی که کد پیش‌بینی نکرده) فقط همین شکل را پنهان می‌کند؛ + * متنِ جواب سرِ جایش می‌ماند. + */ +function Body({ spec }: { spec: DiagramSpec }): ReactNode { + switch (spec.kind) { + case 'plane': return ; + case 'numberline': return ; + case 'solid': return ; + case 'bar': case 'linechart': case 'pie': return ; + case 'venn': return ; + case 'mapping': return ; + case 'tree': return ; + case 'fraction': return ; + case 'grid': return ; + case 'groups': return ; + case 'clock': return ; + case 'table': return ; + default: return null; + } +} + +class Guard extends Component<{ children: ReactNode }, { broken: boolean }> { + state = { broken: false }; + static getDerivedStateFromError() { return { broken: true }; } + render() { return this.state.broken ? null : this.props.children; } +} + +function DiagramBase({ spec }: { spec: DiagramSpec }) { + return ( + + {/* dir=ltr: محور و مختصات چپ‌به‌راست‌اند؛ در زمینه‌ی rtl «−۱» به «۱−» برمی‌گشت. + برچسب‌های فارسی با unicode-bidi: plaintext جهتِ خودشان را می‌گیرند. */} +
+ + {spec.title &&
} +
+
+ ); +} + +export const Diagram = memo(DiagramBase); diff --git a/src/diagram/misc.tsx b/src/diagram/misc.tsx new file mode 100644 index 0000000..cf4b050 --- /dev/null +++ b/src/diagram/misc.tsx @@ -0,0 +1,219 @@ +import type { ClockSpec, FractionSpec, GridSpec, GroupsSpec, TableSpec } from './types'; +import { COLORS, INK, bidi, color, mathText } from './svg'; +import { toFa } from '@/lib/format'; + +const W = 320; +const SHADE = COLORS.yellow; + +/* ─── کسر: دایره یا نوارِ تکه‌تکه ─────────────────────────────────────────── */ + +export function Fraction({ spec }: { spec: FractionSpec }) { + if (spec.compare?.length) return ; + if (spec.wholes) return ; + const { parts, shaded } = spec; + const fill = (i: number) => (i < shaded ? SHADE : '#fff'); + const caption = {`${toFa(shaded)} تکه از ${toFa(parts)} تکه`}; + if (spec.shape === 'rect') { + const w = (W - 40) / parts; + return ( + + {/* از راست به چپ رنگ می‌شود، مثلِ خواندنِ فارسی */} + {Array.from({ length: parts }, (_, i) => )} + {caption} + + ); + } + const cx = W / 2, cy = 80, r = 66; + return ( + + {parts === 1 ? : 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; + return Math.PI ? 1 : 0} 1 ${cx + r * Math.cos(a1)},${cy + r * Math.sin(a1)} Z`} fill={fill(i)} stroke={INK} strokeWidth={2} strokeLinejoin="round" />; + })} + {caption} + + ); +} + +/** چند کسر روی نوارهای هم‌اندازه، برای مقایسه (یک‌پنجم و نُه‌دهم). */ +function FractionCompare({ items }: { items: { parts: number; shaded: number; label?: string }[] }) { + const barW = W - 110, h = 30, gap = 16; + const H = items.length * (h + gap) + 8; + const cols = [COLORS.yellow, COLORS.teal, COLORS.orange, COLORS.purple]; + return ( + + {items.map((f, r) => { + const y = 8 + r * (h + gap), w = barW / f.parts; + return ( + + {Array.from({ length: f.parts }, (_, i) => ( + + ))} + + {f.label ?? `${toFa(f.shaded)} از ${toFa(f.parts)}`} + + + ); + })} + + ); +} + +/** عددِ مخلوط: چند واحدِ کامل و یک کسر (دو و هفت‌دهم). */ +function MixedNumber({ wholes, parts, shaded, shape }: { wholes: number; parts: number; shaded: number; shape: 'circle' | 'rect' }) { + const n = wholes + 1, r = Math.min(40, (W - 20) / n / 2 - 6), cy = 52; + const cx = (i: number) => W - 10 - r - 6 - i * (2 * r + 12); + const slice = (x: number, i: number, full: boolean) => { + const a0 = -Math.PI / 2 + (2 * Math.PI * i) / parts, a1 = -Math.PI / 2 + (2 * Math.PI * (i + 1)) / parts; + return Math.PI ? 1 : 0} 1 ${x + r * Math.cos(a1)},${cy + r * Math.sin(a1)} Z`} fill={full || i < shaded ? SHADE : '#fff'} stroke={INK} strokeWidth={1.6} />; + }; + return ( + + {Array.from({ length: wholes }, (_, w) => (shape === 'rect' + ? + : ))} + {parts === 1 ? : Array.from({ length: parts }, (_, i) => slice(cx(wholes), i, false))} + + {`${toFa(wholes)} واحدِ کامل و ${toFa(shaded)} تکه از ${toFa(parts)}`} + + + ); +} + +/* ─── شبکه‌ی خانه‌ها: درصد، مساحت با شمردن، کسر روی جدول ─────────────────── */ + +export function Grid({ spec }: { spec: GridSpec }) { + const rows = spec.rows, cols = spec.cols; + const cell = Math.min(26, (W - 30) / cols, 220 / rows); + const gw = cols * cell, gh = rows * cell; + const x0 = (W - gw) / 2, y0 = 12; + const on = new Set(); + if (typeof spec.shaded === 'number') { + // سطر به سطر، از بالا و از راست (جهتِ خواندنِ فارسی) + for (let i = 0; i < Math.min(spec.shaded, rows * cols); i++) on.add(`${Math.floor(i / cols)},${cols - 1 - (i % cols)}`); + } else spec.shaded.forEach(([r, c]) => on.add(`${r},${c}`)); + const col = color(spec.color, SHADE); + const count = on.size; + return ( + + {Array.from({ length: rows }, (_, r) => Array.from({ length: cols }, (_, c) => ( + + )))} + + + {`${toFa(count)} خانه از ${toFa(rows * cols)} خانه${100 % (rows * cols) === 0 || (rows * cols) % 10 === 0 ? `، یعنی ${toFa(Math.round((count / (rows * cols)) * 1000) / 10)}٪` : ''}`} + + + ); +} + +/* ─── گروه‌ها: مدلِ ضرب و تقسیم برای دبستان ───────────────────────────────── */ + +export function Groups({ spec }: { spec: GroupsSpec }) { + const g = spec.groups, each = spec.each; + const perRow = Math.min(g, g <= 4 ? g : Math.ceil(g / 2)); + const rowsG = Math.ceil(g / perRow); + const inner = Math.ceil(Math.sqrt(each)); + const dot = Math.min(14, 64 / inner); + const boxW = inner * dot + 16, boxH = Math.ceil(each / inner) * dot + 16; + const gap = 12; + const totalW = perRow * boxW + (perRow - 1) * gap; + const scale = Math.min(1, (W - 20) / totalW); + const H = (rowsG * (boxH + gap) + 30) * scale + 10; + const shape = spec.item ?? 'circle'; + const cols = [COLORS.red, COLORS.blue, COLORS.green, COLORS.orange, COLORS.purple]; + return ( + + + {Array.from({ length: g }, (_, gi) => { + const bx = (gi % perRow) * (boxW + gap), by = Math.floor(gi / perRow) * (boxH + gap); + const c = cols[gi % cols.length]; + return ( + + + {Array.from({ length: each }, (_, i) => { + const x = 8 + (i % inner) * dot + dot / 2, y = 8 + Math.floor(i / inner) * dot + dot / 2, s = dot * 0.36; + if (shape === 'square') return ; + if (shape === 'star') return ; + return ; + })} + + ); + })} + + + {mathText(spec.label ?? `${g} × ${each} = ${g * each}`)} + + + ); +} + +function star(cx: number, cy: number, r: number): string { + const pts = Array.from({ length: 10 }, (_, i) => { + const t = -Math.PI / 2 + (i * Math.PI) / 5, rr = i % 2 ? r * 0.45 : r; + return `${cx + rr * Math.cos(t)},${cy + rr * Math.sin(t)}`; + }); + return `M${pts.join(' L')} Z`; +} + +/* ─── ساعت ────────────────────────────────────────────────────────────────── */ + +export function Clock({ spec }: { spec: ClockSpec }) { + const cx = W / 2, cy = 100, r = 84; + const h = ((spec.hour % 12) + spec.minute / 60) * 30, m = spec.minute * 6; + const hand = (deg: number, len: number, w: number, c: string) => { + const t = ((deg - 90) * Math.PI) / 180; + return ; + }; + return ( + + + {Array.from({ length: 60 }, (_, i) => { + const t = (i * 6 - 90) * (Math.PI / 180), big = i % 5 === 0; + return ; + })} + {Array.from({ length: 12 }, (_, i) => { + const n = i + 1, t = (n * 30 - 90) * (Math.PI / 180); + return {toFa(n)}; + })} + {hand(h, r * 0.5, 5, COLORS.blue)} + {hand(m, r * 0.78, 3, COLORS.red)} + + + ); +} + +/* ─── جدول (مقدارها، تعیینِ علامت) ───────────────────────────────────────── */ + +export function Table({ spec }: { spec: TableSpec }) { + const cellStyle = (v: string) => { + const t = v.trim(); + if (t === '+') return { color: COLORS.green, fontWeight: 900 }; + if (t === '-' || t === '−') return { color: COLORS.red, fontWeight: 900 }; + if (t === '0' || t === '۰') return { color: INK, fontWeight: 900 }; + return undefined; + }; + return ( + // جدولِ ریاضی (تعیینِ علامت، مقدارها) چپ‌به‌راست است، مثلِ محورِ x. +
+
+ {spec.headers.length > 0 && ( + + {spec.headers.map((h, i) => )} + + )} + + {spec.rows.map((row, r) => ( + + {row.map((c, i) => ( + + ))} + + ))} + +
{mathText(h)}
+ {mathText(c)} +
+ + ); +} diff --git a/src/diagram/numberline.tsx b/src/diagram/numberline.tsx new file mode 100644 index 0000000..b452914 --- /dev/null +++ b/src/diagram/numberline.tsx @@ -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([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 ( + + color(iv.color, COLORS[SERIES[i % SERIES.length]]))]} /> + + {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 ( + + + {fr ? ( + + ) : hideNumber ? null : ( + {fa(t)} + )} + + ); + })} + + {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 ( + + + {iv.from !== null && } + {iv.to !== null && } + {iv.label && } + + ); + })} + + {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 ( + + + {j.label && } + + ); + })} + + {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 ( + + + {p.label && !numeric && } + + ); + })} + + ); +} diff --git a/src/diagram/plane.tsx b/src/diagram/plane.tsx new file mode 100644 index 0000000..90cb50f --- /dev/null +++ b/src/diagram/plane.tsx @@ -0,0 +1,693 @@ +import { useId, type ReactNode } from 'react'; +import type { ColorName, FunctionMark, PlaneItem, PlaneSpec, PointDef, Ref, Transform } from './types'; +import { derivative, extrema, parseExpr, roots } from './expr'; +import { Arrowheads, COLORS, GRID, INK, Label, PAPER, PERSIAN_LETTER, bidi, SERIES, SOFT, arrowId, color, fa, ltr, mathText, niceStep, piLabel, ticksBetween } from './svg'; + +/** + * صفحه‌ی مختصات و هندسه. همه‌ی مختصات اینجا حساب می‌شوند: + * نقطه‌ها polar، وسطِ پاره‌خط، پای عمود، محلِ برخوردِ دو خط، نقطه‌ی روی نمودار + * تابع‌ها ریشه، رأس/اکسترمم، عرض از مبدأ، محورِ تقارن، شیبِ مماس + * تبدیل‌ها دوران، قرینه، انتقال، تجانس + * مدل فقط می‌گوید کدام را می‌خواهد. هر تکه‌ای که حل نشود (نقطه‌ی ناشناخته، فرمولِ + * نامعتبر) بی‌صدا حذف می‌شود و بقیه‌ی شکل کشیده می‌شود. + */ + +type XY = [number, number]; +const W = 320; +const PAD = 24; + +const GEOMETRY = new Set(['circle', 'arc', 'sector', 'angle', 'rightAngle', 'polygon', 'image']); + +/* ─── هندسه‌ی نقطه‌ها ─────────────────────────────────────────────────────── */ + +function footOf(p: XY, a: XY, b: XY): XY { + const [dx, dy] = [b[0] - a[0], b[1] - a[1]]; + const t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / (dx * dx + dy * dy || 1); + return [a[0] + t * dx, a[1] + t * dy]; +} + +function intersect(a: XY, b: XY, c: XY, d: XY): XY | null { + const den = (a[0] - b[0]) * (c[1] - d[1]) - (a[1] - b[1]) * (c[0] - d[0]); + if (Math.abs(den) < 1e-12) return null; + const t = ((a[0] - c[0]) * (c[1] - d[1]) - (a[1] - c[1]) * (c[0] - d[0])) / den; + return [a[0] + t * (b[0] - a[0]), a[1] + t * (b[1] - a[1])]; +} + +function transformPoint(p: XY, tr: Transform, ref: (r: Ref) => XY | null): XY | null { + if ('rotate' in tr) { + const c = ref(tr.rotate.center); + if (!c) return null; + // دوران مثبت = پادساعتگرد، مثلِ کتاب؛ «در جهتِ عقربه‌ها» یعنی زاویه‌ی منفی. + const t = (tr.rotate.deg * Math.PI) / 180, [x, y] = [p[0] - c[0], p[1] - c[1]]; + return [c[0] + x * Math.cos(t) - y * Math.sin(t), c[1] + x * Math.sin(t) + y * Math.cos(t)]; + } + if ('translate' in tr) return [p[0] + tr.translate[0], p[1] + tr.translate[1]]; + if ('scale' in tr) { + const c = ref(tr.scale.center); + return c ? [c[0] + tr.scale.k * (p[0] - c[0]), c[1] + tr.scale.k * (p[1] - c[1])] : null; + } + const r = tr.reflect; + if (r === 'x') return [p[0], -p[1]]; + if (r === 'y') return [-p[0], p[1]]; + if (r === 'y=x') return [p[1], p[0]]; + const a = ref(r[0]), b = ref(r[1]); + if (!a || !b) return null; + const f = footOf(p, a, b); + return [2 * f[0] - p[0], 2 * f[1] - p[1]]; +} + +/** نقطه‌های نام‌دار، با حلِ ارجاعِ زنجیره‌ای و بدونِ حلقه‌ی بی‌پایان. */ +function resolvePoints(spec: PlaneSpec) { + const named = new Map(); + const resolving = new Set(); + const ref = (r: Ref): XY | null => (typeof r === 'string' ? byName(r) : [r[0], r[1]]); + function byName(name: string): XY | null { + if (named.has(name)) return named.get(name)!; + const def: PointDef | undefined = spec.points[name]; + if (def === undefined || resolving.has(name)) return null; + resolving.add(name); + const xy = build(def); + resolving.delete(name); + if (xy && Number.isFinite(xy[0]) && Number.isFinite(xy[1])) named.set(name, xy); + return named.get(name) ?? null; + } + function build(def: PointDef): XY | null { + if (Array.isArray(def)) return [def[0], def[1]]; + if ('polar' in def) { + const o = def.from !== undefined ? ref(def.from) : [0, 0]; + if (!o) return null; + const t = (def.polar[1] * Math.PI) / 180; + return [o[0] + def.polar[0] * Math.cos(t), o[1] + def.polar[0] * Math.sin(t)]; + } + if ('mid' in def) { + const a = ref(def.mid[0]), b = ref(def.mid[1]); + return a && b ? [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2] : null; + } + if ('foot' in def) { + const p = ref(def.foot[0]), a = ref(def.foot[1]), b = ref(def.foot[2]); + return p && a && b ? footOf(p, a, b) : null; + } + if ('intersect' in def) { + const [[a, b], [c, d]] = def.intersect; + const pa = ref(a), pb = ref(b), pc = ref(c), pd = ref(d); + return pa && pb && pc && pd ? intersect(pa, pb, pc, pd) : null; + } + if ('along' in def) { + const a = ref(def.along[0]), b = ref(def.along[1]), t = def.along[2]; + return a && b ? [a[0] + t * (b[0] - a[0]), a[1] + t * (b[1] - a[1])] : null; + } + if ('fx' in def) { + try { + const x = def.fx[1]; + return [x, parseExpr(def.fx[0])(x)]; + } catch { + return null; + } + } + return null; + } + Object.keys(spec.points).forEach(byName); + return { named, ref }; +} + +/* ─── نمونه‌برداری از نمودار، با شکستن در مجانب ──────────────────────────── */ + +function sample(f: (x: number) => number, from: number, to: number, n = 360): XY[][] { + const runs: XY[][] = []; + let run: XY[] = []; + let prev: number | null = null; + for (let i = 0; i <= n; i++) { + const x = from + ((to - from) * i) / n, y = f(x); + const jump = prev !== null && Number.isFinite(y) && Math.abs(y - prev) > 50 * (Math.abs(prev) + Math.abs(y) + 1) / Math.max(1, n / 60); + if (!Number.isFinite(y) || jump) { + if (run.length > 1) runs.push(run); + run = Number.isFinite(y) ? [[x, y]] : []; + } else run.push([x, y]); + prev = Number.isFinite(y) ? y : null; + } + if (run.length > 1) runs.push(run); + return runs; +} + +interface Curve { f: (x: number) => number; from: number; to: number; runs: XY[][] } + +function compile(expr: string, from: number, to: number): Curve | null { + try { + const f = parseExpr(expr); + const runs = sample(f, from, to); + return runs.length ? { f, from, to, runs } : null; + } catch { + return null; + } +} + +/* ─── برچسبِ ترکیبی: «رأس (۱، −۱)» دو سطر می‌شود تا جهتِ متن به هم نریزد ── */ + +export function splitLabel(text: string): [string, string] | null { + if (!PERSIAN_LETTER.test(text)) return null; + const m = /^([^()\d۰-۹=]*?[؀-ۿ][^()\d۰-۹=]*?)\s*([(\d۰-۹−\-+].*)$/.exec(text); + return m && /[\d۰-۹]/.test(m[2]) ? [m[1].trim(), m[2].trim()] : null; +} + +/** رقمِ لاتین به فارسی و منفیِ درست، در متنِ ریاضیِ برچسب‌ها. */ +export { mathText }; + +function RichLabel({ x, y, text, fill, size = 11, anchor = 'middle' }: { x: number; y: number; text: string; fill: string; size?: number; anchor?: 'start' | 'middle' | 'end' }) { + const parts = splitLabel(text); + if (!parts) return ; + return ( + + + {mathText(parts[1])} + + ); +} + +/* ─── خودِ شکل ───────────────────────────────────────────────────────────── */ + +export function Plane({ spec }: { spec: PlaneSpec }) { + // شناسه‌ی یکتا: چند شکل در یک گفتگو نباید clipPathِ همدیگر را بگیرند. + const clipId = `dg-plot-${useId().replace(/:/g, '')}`; + const { named, ref } = resolvePoints(spec); + + // تصویرِ تبدیل‌ها پیش از همه، تا A′ مثلِ هر نقطه‌ی دیگر قابلِ ارجاع باشد. + const images: { item: Extract; pts: XY[]; names: string[] }[] = []; + for (const item of spec.items) { + if (item.type !== 'image') continue; + const pts: XY[] = [], names: string[] = []; + item.of.forEach((r, i) => { + const p = ref(r); + const q = p && transformPoint(p, item.transform, ref); + if (!q) return; + const name = item.names?.[i] ?? (typeof r === 'string' ? `${r}′` : ''); + pts.push(q); names.push(name); + if (name && !named.has(name)) named.set(name, q); + }); + if (pts.length) images.push({ item, pts, names }); + } + + // نمودارها، برای کادر و برای رسم. + let series = 0; + const nextColor = (c?: ColorName) => COLORS[c ?? SERIES[series++ % SERIES.length]]; + const curves = new Map(); + for (const item of spec.items) { + if (item.type === 'function' || item.type === 'area' || item.type === 'region') { + const range = item.type === 'region' ? [item.from ?? -50, item.to ?? 50] : [item.from, item.to]; + const c = compile(item.expr, range[0], range[1]); + if (c) curves.set(item, [c]); + if (item.type === 'area' && item.expr2) { + const c2 = compile(item.expr2, item.from, item.to); + if (c && c2) curves.set(item, [c, c2]); + } + } else if (item.type === 'piecewise') { + const cs = item.pieces.map((p) => compile(p.expr, p.from, p.to)).filter((c): c is Curve => c !== null); + if (cs.length) curves.set(item, cs); + } else if (item.type === 'tangent') { + try { + const f = parseExpr(item.expr); + if (Number.isFinite(f(item.at))) curves.set(item, [{ f, from: item.at, to: item.at, runs: [] }]); + } catch { /* فرمولِ نامعتبر */ } + } + } + + // نقطه‌های ویژه‌ی نمودار (ریشه، رأس، …) که کد حساب می‌کند. + const markPts: { xy: XY; kind: FunctionMark | 'max' | 'min'; color: string }[] = []; + const axisLines: number[] = []; + for (const item of spec.items) { + if (item.type !== 'function' || !item.marks?.length) continue; + const c = curves.get(item)?.[0]; + if (!c) continue; + const want = new Set(item.marks); + if (want.has('roots')) for (const r of roots(c.f, c.from, c.to).slice(0, 6)) markPts.push({ xy: [r, 0], kind: 'roots', color: COLORS.green }); + if (want.has('vertex') || want.has('extrema') || want.has('axis')) { + const ex = extrema(c.f, c.from, c.to).slice(0, 6); + if (want.has('vertex') || want.has('extrema')) for (const e of ex) markPts.push({ xy: [e.x, e.y], kind: want.has('vertex') ? 'vertex' : e.kind, color: COLORS.red }); + if (want.has('axis') && ex.length === 1) axisLines.push(ex[0].x); + } + if (want.has('yIntercept') && c.from <= 0 && c.to >= 0) { + const y = c.f(0); + if (Number.isFinite(y)) markPts.push({ xy: [0, y], kind: 'yIntercept', color: COLORS.orange }); + } + } + + /* ── کادر ── */ + const xs: number[] = [], ys: number[] = []; + const add = (p: XY | null) => { if (p) { xs.push(p[0]); ys.push(p[1]); } }; + named.forEach((p) => add(p)); + images.forEach((im) => im.pts.forEach(add)); + markPts.forEach((m) => add(m.xy)); + for (const item of spec.items) { + switch (item.type) { + case 'circle': case 'arc': case 'sector': { + const c = ref(item.center); + const through = item.type === 'circle' && item.through ? ref(item.through) : null; + const r = item.type === 'circle' ? (through && c ? Math.hypot(through[0] - c[0], through[1] - c[1]) : item.r ?? 0) : item.r; + if (c) { add([c[0] - r, c[1] - r]); add([c[0] + r, c[1] + r]); } + break; + } + case 'segment': case 'arrow': case 'vector': add(ref(item.from)); add(ref(item.to)); break; + case 'ray': add(ref(item.from)); add(ref(item.through)); break; + case 'line': { const [a, b] = lineEnds(item, ref); if (!item.eq) { add(a); add(b); } else if (a) add(a); break; } + case 'polygon': item.points.forEach((p) => add(ref(p))); break; + case 'point': add(ref(item.point)); break; + case 'text': add(item.at); break; + case 'vline': xs.push(item.x); break; + case 'hline': ys.push(item.y); break; + case 'tangent': { const c = curves.get(item)?.[0]; if (c) add([item.at, c.f(item.at)]); break; } + default: break; + } + } + for (const [item, cs] of curves) { + if (item.type === 'region' || item.type === 'tangent') continue; + for (const c of cs) { + xs.push(c.from, c.to); + const all = c.runs.flat().map((p) => p[1]).sort((a, b) => a - b); + if (all.length) ys.push(all[Math.floor(all.length * 0.04)], all[Math.ceil(all.length * 0.96) - 1]); + } + } + if (spec.axes) { xs.push(0); ys.push(0); } + if (!xs.length || !ys.length) return null; + + let [x0, x1] = spec.xRange ?? [Math.min(...xs), Math.max(...xs)]; + let [y0, y1] = spec.yRange ?? [Math.min(...ys), Math.max(...ys)]; + if (x1 - x0 < 1e-9) { x0 -= 1; x1 += 1; } + if (y1 - y0 < 1e-9) { y0 -= 1; y1 += 1; } + if (!spec.xRange) { const m = (x1 - x0) * 0.1 + 0.15; x0 -= m; x1 += m; } + if (!spec.yRange) { const m = (y1 - y0) * 0.1 + 0.15; y0 -= m; y1 += m; } + + // مقیاسِ یکسان برای هندسه (دایره نباید بیضی شود)؛ برای نمودارِ تنها، ارتفاع سقف دارد. + const geometry = spec.items.some((i) => GEOMETRY.has(i.type)); + const plotW = W - 2 * PAD; + let sx = plotW / (x1 - x0), sy = sx; + let H = (y1 - y0) * sy + 2 * PAD; + const [minH, maxH] = [170, 380]; + if (H > maxH) { + if (geometry) { sy = sx = (maxH - 2 * PAD) / (y1 - y0); } else sy = (maxH - 2 * PAD) / (y1 - y0); + H = maxH; + } else if (H < minH && !geometry) { + sy = (minH - 2 * PAD) / (y1 - y0); + H = minH; + } else H = Math.max(H, 120); + const ox = (W - (x1 - x0) * sx) / 2, oy = (H - (y1 - y0) * sy) / 2; + const X = (x: number) => ox + (x - x0) * sx; + const Y = (y: number) => oy + (y1 - y) * sy; + const P = (p: XY): XY => [X(p[0]), Y(p[1])]; + const path = (pts: XY[]) => pts.map((p, i) => `${i ? 'L' : 'M'}${X(p[0]).toFixed(2)},${Y(p[1]).toFixed(2)}`).join(' '); + + const layers = { back: [] as ReactNode[], fill: [] as ReactNode[], mid: [] as ReactNode[], curves: [] as ReactNode[], marks: [] as ReactNode[], points: [] as ReactNode[], labels: [] as ReactNode[] }; + const arrowColors = new Set([SOFT]); + let key = 0; + const k = () => key++; + const clip = `url(#${clipId})`; + + const centroid: XY = (() => { + const all = [...named.values()]; + return all.length ? [all.reduce((a, p) => a + p[0], 0) / all.length, all.reduce((a, p) => a + p[1], 0) / all.length] : [(x0 + x1) / 2, (y0 + y1) / 2]; + })(); + const away = (p: XY, dist = 13): XY => { + const [px, py] = P(p), [cx, cy] = P(centroid); + let [dx, dy] = [px - cx, py - cy]; + const n = Math.hypot(dx, dy); + if (n < 1e-6) { dx = 0.6; dy = -0.8; } else { dx /= n; dy /= n; } + return [px + dx * dist, py + dy * dist + 4]; + }; + + /* ── شبکه و محور ── */ + const trig = spec.trig ?? false; + const xstep = trig ? (x1 - x0 > 5 * Math.PI ? Math.PI : Math.PI / 2) : spec.grid ? Math.max(1, niceStep(x1 - x0, 12)) : niceStep(x1 - x0, 8); + const ystep = spec.grid ? Math.max(1, niceStep(y1 - y0, 12)) : niceStep(y1 - y0, 7); + const xticks = ticksBetween(x0, x1, xstep), yticks = ticksBetween(y0, y1, ystep); + if (spec.axes || spec.grid) { + layers.back.push( + + {xticks.map((t) => )} + {yticks.map((t) => )} + , + ); + } + if (spec.axes) { + const ax = x0 <= 0 && x1 >= 0 ? X(0) : X(x0), ay = y0 <= 0 && y1 >= 0 ? Y(0) : Y(y0); + layers.back.push( + + + + , + + {xticks.filter((t) => Math.abs(t) > 1e-9).map((t) => ( + + + {trig ? piLabel(t) : fa(t)} + + ))} + {yticks.filter((t) => Math.abs(t) > 1e-9).map((t) => ( + + + {fa(t)} + + ))} + {x0 <= 0 && x1 >= 0 && y0 <= 0 && y1 >= 0 && ۰} + x + y + , + ); + } + + /* ── تکه‌ها ── */ + for (const item of spec.items) { + const itemColor = (fallback: string) => color(item.color, fallback); + switch (item.type) { + case 'circle': { + const c = ref(item.center); + if (!c) break; + const through = item.through ? ref(item.through) : null; + const r = through ? Math.hypot(through[0] - c[0], through[1] - c[1]) : item.r ?? 0; + if (r <= 0) break; + const col = itemColor(COLORS.blue); + layers.mid.push(); + if (item.label) layers.labels.push(); + break; + } + case 'arc': case 'sector': { + const c = ref(item.center); + if (!c) break; + const col = itemColor(item.type === 'sector' ? COLORS.orange : COLORS.red); + const a0 = (item.start * Math.PI) / 180, a1 = (item.end * Math.PI) / 180; + const pts: XY[] = []; + for (let i = 0; i <= 64; i++) { const t = a0 + ((a1 - a0) * i) / 64; pts.push([c[0] + item.r * Math.cos(t), c[1] + item.r * Math.sin(t)]); } + const d = item.type === 'sector' ? `${path([c, ...pts])} Z` : path(pts); + layers.mid.push(); + if (item.label) { + const m = (a0 + a1) / 2, rr = item.type === 'arc' ? item.r * 1.16 : item.r * 0.6; + layers.labels.push(); + } + break; + } + case 'polygon': { + const pts = item.points.map(ref).filter((p): p is XY => p !== null); + if (pts.length < 3) break; + const col = itemColor(COLORS.blue); + layers.fill.push(); + if (item.label) { + const cx = pts.reduce((a, p) => a + p[0], 0) / pts.length, cy = pts.reduce((a, p) => a + p[1], 0) / pts.length; + layers.labels.push(); + } + break; + } + case 'segment': case 'arrow': case 'vector': { + const a = ref(item.from), b = ref(item.to); + if (!a || !b) break; + const isArrow = item.type !== 'segment'; + const col = itemColor(isArrow ? (item.type === 'vector' ? COLORS.purple : COLORS.red) : INK); + if (isArrow) arrowColors.add(col); + const [ax, ay] = P(a), [bx, by] = P(b); + layers.mid.push(); + if (item.type === 'segment' && item.ticks) layers.mid.push(); + if (item.label) { + const [lx, ly, anchor] = sideOffset([ax, ay], [bx, by], P(centroid)); + layers.labels.push(); + } + break; + } + case 'line': case 'ray': { + const [a, b] = item.type === 'line' ? lineEnds(item, ref) : [ref(item.from), ref(item.through)]; + if (!a || !b || (a[0] === b[0] && a[1] === b[1])) break; + const col = itemColor(COLORS.blue); + const [dx, dy] = [b[0] - a[0], b[1] - a[1]]; + const t = (Math.abs(x1 - x0) + Math.abs(y1 - y0)) * 3 / Math.hypot(dx, dy); + const s: XY = item.type === 'line' ? [a[0] - dx * t, a[1] - dy * t] : a; + const e: XY = [a[0] + dx * t, a[1] + dy * t]; + layers.mid.push(); + if (item.label) { + const lp = edgePoint(a, b, x0, x1, y0, y1); + layers.labels.push(); + } + break; + } + case 'vline': case 'hline': { + const col = itemColor(COLORS.gray); + const dash = item.dashed === false ? undefined : '5 4'; + if (item.type === 'vline') { + layers.mid.push(); + if (item.label) layers.labels.push(); + } else { + layers.mid.push(); + if (item.label) layers.labels.push(); + } + break; + } + case 'function': case 'piecewise': { + const cs = curves.get(item); + if (!cs) break; + const col = nextColor(item.color); + cs.forEach((c) => c.runs.forEach((run) => layers.curves.push( + , + ))); + if (item.type === 'piecewise') { + item.pieces.forEach((p) => { + let f: (x: number) => number; + try { f = parseExpr(p.expr); } catch { return; } + ([[p.from, p.closedFrom ?? true], [p.to, p.closedTo ?? true]] as [number, boolean][]).forEach(([x, closed]) => { + const y = f(x); + if (Number.isFinite(y) && Math.abs(x) < 1e3) layers.points.push(); + }); + }); + } + if (item.label) { + // سرِ راستِ بخشِ دیده‌شده‌ی نمودار، کمی دورتر از خودِ خط. + const vis = cs.flatMap((c) => c.runs.flat()).filter(([x, y]) => x >= x0 && x <= x1 && y >= y0 + (y1 - y0) * 0.04 && y <= y1 - (y1 - y0) * 0.04); + const at = vis[vis.length - 1]; + if (at) { + const slope = vis.length > 2 ? (Y(at[1]) - Y(vis[vis.length - 3][1])) : 0; + layers.labels.push(); + } + } + break; + } + case 'tangent': { + const c = curves.get(item)?.[0]; + if (!c) break; + const col = itemColor(COLORS.red); + const y = c.f(item.at), m = derivative(c.f, item.at); + const span = (x1 - x0) * 2; + layers.curves.push(); + layers.points.push(); + const text = item.label ?? `شیب = ${fa(m)}`; + layers.labels.push( 0 ? 18 : -10)} text={text} fill={col} />); + break; + } + case 'area': { + const cs = curves.get(item); + if (!cs) break; + const col = nextColor(item.color); + const top = cs[0].runs.flat(); + const bottom: XY[] = cs[1] ? cs[1].runs.flat().slice().reverse() : [[item.to, 0], [item.from, 0]]; + layers.fill.push(); + if (item.label) { + const mid = top[Math.floor(top.length / 2)]; + layers.labels.push(); + } + break; + } + case 'region': { + const c = curves.get(item)?.[0]; + if (!c) break; + const col = itemColor(COLORS.blue); + const edge = item.side === 'above' ? y1 + (y1 - y0) : y0 - (y1 - y0); + c.runs.forEach((run) => { + const poly: XY[] = [...run, [run[run.length - 1][0], edge], [run[0][0], edge]]; + layers.fill.push(); + layers.curves.push(); + }); + if (item.label) { + // جایی داخلِ ناحیه: بینِ نمودار و لبه‌ی کادر، در یک‌چهارمِ عرض از سمتی که ناحیه پهن‌تر است. + const candidates = [0.2, 0.5, 0.8].map((t) => x0 + (x1 - x0) * t); + let best = candidates[0], room = -Infinity; + for (const x of candidates) { + const y = c.f(x); + const r = item.side === 'above' ? y1 - y : y - y0; + if (Number.isFinite(r) && r > room) { room = r; best = x; } + } + const yc = c.f(best); + const yl = item.side === 'above' ? (Math.min(yc, y1) + y1) / 2 : (Math.max(yc, y0) + y0) / 2; + layers.labels.push(); + } + break; + } + case 'angle': case 'rightAngle': { + const v = ref(item.vertex), a = ref(item.from), b = ref(item.to); + if (!v || !a || !b) break; + const col = itemColor(COLORS.red); + 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.max(12, Math.min(26, Math.hypot(ax - vx, ay - vy) * 0.35, Math.hypot(bx - vx, by - vy) * 0.35)); + if (item.type === 'rightAngle') { + const q = Math.min(r, 12); + const ua: XY = [Math.cos(ta), Math.sin(ta)], ub: XY = [Math.cos(tb), Math.sin(tb)]; + 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]; + layers.marks.push(); + } else { + const arcs = Math.max(1, Math.min(3, item.arcs ?? 1)); + for (let i = 0; i < arcs; i++) { + const rr = r + i * 4; + const s: XY = [vx + rr * Math.cos(ta), vy + rr * Math.sin(ta)], e: XY = [vx + rr * Math.cos(ta + d), vy + rr * Math.sin(ta + d)]; + layers.marks.push( + 0 ? 1 : 0} ${e.join(',')} Z` : `M${s.join(',')} A${rr},${rr} 0 0 ${d > 0 ? 1 : 0} ${e.join(',')}`} + fill={i === 0 ? `${col}2a` : 'none'} stroke={col} strokeWidth={1.7} />, + ); + } + } + if (item.label) { + const m = ta + d / 2, lr = r + 15 + ((item.type === 'angle' ? item.arcs ?? 1 : 1) - 1) * 4; + layers.labels.push(); + } + break; + } + case 'text': { + layers.labels.push(); + break; + } + default: break; + } + } + + /* ── تصویرِ تبدیل‌ها ── */ + for (const im of images) { + const col = color(im.item.color, COLORS.purple); + if (im.pts.length >= 3) layers.fill.push(); + else if (im.pts.length === 2) layers.mid.push(); + im.pts.forEach((p, i) => { + layers.points.push(); + if (im.names[i]) { const [lx, ly] = away(p); layers.labels.push(); } + }); + } + + /* ── نقطه‌های ویژه‌ی نمودار ── */ + for (const x of axisLines) { + layers.mid.push(); + layers.labels.push(); + } + const markName: Record = { vertex: 'رأس', max: 'ماکزیمم', min: 'مینیمم', yIntercept: '', roots: '' }; + // عددِ نزدیک به مضربِ π/2 در نمودارِ مثلثاتی به صورتِ π نوشته می‌شود (۳٫۱۴ → π). + const fx = (x: number) => (trig && Math.abs(x / (Math.PI / 2) - Math.round(x / (Math.PI / 2))) < 1e-6 ? piLabel(x) : fa(x)); + // بیش از چهار نقطه از یک نوع (ریشه‌های سینوس) فقط نقطه می‌گیرند، نه برچسب؛ وگرنه روی هم می‌افتند. + const kindCount = new Map(); + markPts.forEach((m) => kindCount.set(m.kind === 'max' || m.kind === 'min' ? 'ext' : m.kind, (kindCount.get(m.kind === 'max' || m.kind === 'min' ? 'ext' : m.kind) ?? 0) + 1)); + for (const m of markPts) { + const [px, py] = P(m.xy); + if (px < 0 || px > W || py < 0 || py > H) continue; + layers.points.push(); + const crowded = (kindCount.get(m.kind === 'max' || m.kind === 'min' ? 'ext' : m.kind) ?? 0) > 4; + if (crowded) continue; + const coord = ltr(`(${fx(m.xy[0])}, ${fa(m.xy[1])})`); + const below = m.kind === 'min' || m.kind === 'vertex' ? m.xy[1] <= (y0 + y1) / 2 : m.kind === 'roots'; + const ly = below ? py + 16 : py - 9; + const name = markName[m.kind]; + layers.labels.push( + + {name && } + {coord} + , + ); + } + + /* ── نقطه‌ها ── */ + const explicit = new Map>(); + const loosePoints: Extract[] = []; + for (const item of spec.items) { + if (item.type !== 'point') continue; + if (typeof item.point === 'string') explicit.set(item.point, item); else loosePoints.push(item); + } + const drawPoint = (xy: XY, label: string, col: string, open = false) => { + const [px, py] = P(xy); + layers.points.push(); + if (label) { const [lx, ly] = away(xy, 14); layers.labels.push(); } + }; + const imageNames = new Set(images.flatMap((im) => im.names)); + named.forEach((xy, name) => { + if (name.startsWith('_') || imageNames.has(name)) return; + const it = explicit.get(name); + // مبدأ روی محور خودش «۰» دارد؛ فقط اگر مدل صریحاً نقطه‌اش را خواسته باشد برچسب می‌گیرد. + if (spec.axes && !it && Math.abs(xy[0]) < 1e-9 && Math.abs(xy[1]) < 1e-9) return; + drawPoint(xy, it?.label ?? name, color(it?.color, INK), it?.open); + }); + loosePoints.forEach((it) => drawPoint(it.point as XY, it.label ?? '', color(it.color, INK), it.open)); + + return ( + + + + + + {layers.back}{layers.fill}{layers.mid}{layers.curves}{layers.marks}{layers.points}{layers.labels} + + ); +} + +/** + * دو نقطه از خط: یا through، یا از معادله‌ی ax + by = c (ضریب‌ها از صورتِ مسئله؛ + * شیب را کد حساب می‌کند، چون مدل در بازنویسیِ 3x + 5y = 4 به y = … علامت را گم کرده بود). + */ +function lineEnds(item: Extract, ref: (r: Ref) => XY | null): [XY | null, XY | null] { + if (item.eq) { + const [a, b, c] = item.eq; + if (Math.abs(b) > 1e-12) return [[0, c / b], [1, (c - a) / b]]; + if (Math.abs(a) > 1e-12) return [[c / a, 0], [c / a, 1]]; + return [null, null]; + } + return item.through ? [ref(item.through[0]), ref(item.through[1])] : [null, null]; +} + +/** علامتِ تساویِ ضلع: یک، دو یا سه خطِ کوچک عمود بر وسطِ پاره‌خط. */ +function Ticks({ a, b, n, col }: { a: XY; b: XY; n: number; col: string }) { + const [mx, my] = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]; + const len = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1; + const [ux, uy] = [(b[0] - a[0]) / len, (b[1] - a[1]) / len]; + const [nx, ny] = [-uy, ux]; + const count = Math.max(1, Math.min(3, n)); + return ( + + {Array.from({ length: count }, (_, i) => { + const o = (i - (count - 1) / 2) * 4.5; + const cx = mx + ux * o, cy = my + uy * o; + return ; + })} + + ); +} + +/** + * جای برچسبِ ضلع: وسطِ پاره‌خط، به سمتِ بیرونِ شکل. برای خطِ تقریباً عمودی برچسب + * از کنارِ خط شروع می‌شود (نه وسط‌چین)، وگرنه متنِ بلند روی خودِ خط می‌افتد. + */ +function sideOffset(a: XY, b: XY, center: XY): [number, number, 'start' | 'middle' | 'end'] { + 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; } + const anchor = nx > 0.6 ? 'start' : nx < -0.6 ? 'end' : 'middle'; + const d = anchor === 'middle' ? 14 : 8; + return [mx + nx * d, my + ny * d + 4, anchor]; +} + +/** نقطه‌ای از خط که داخلِ کادر و نزدیکِ لبه‌ی راستِ آن است، برای برچسبِ خط. */ +function edgePoint(a: XY, b: XY, x0: number, x1: number, y0: number, y1: number): XY { + let [dx, dy] = [b[0] - a[0], b[1] - a[1]]; + const n = Math.hypot(dx, dy) || 1; + [dx, dy] = [dx / n, dy / n]; + if (dx < 0 || (dx === 0 && dy < 0)) [dx, dy] = [-dx, -dy]; + const inside = (p: XY) => p[0] > x0 + (x1 - x0) * 0.1 && p[0] < x1 - (x1 - x0) * 0.1 && p[1] > y0 + (y1 - y0) * 0.1 && p[1] < y1 - (y1 - y0) * 0.1; + const span = Math.hypot(x1 - x0, y1 - y0); + let best: XY | null = null; + for (let t = -span * 2; t <= span * 2; t += span / 200) { + const p: XY = [a[0] + dx * t, a[1] + dy * t]; + if (inside(p)) best = p; // آخرین نقطه‌ی داخلِ کادر در جهتِ راست + } + return best ?? [(x0 + x1) / 2, (y0 + y1) / 2]; +} diff --git a/src/diagram/sets.tsx b/src/diagram/sets.tsx new file mode 100644 index 0000000..5017115 --- /dev/null +++ b/src/diagram/sets.tsx @@ -0,0 +1,166 @@ +import { useId } from 'react'; +import type { MappingSpec, TreeNode, TreeSpec, VennSpec } from './types'; +import { Arrowheads, COLORS, INK, Label, SERIES, arrowId, mathText } from './svg'; + +const W = 320; + +/* ─── نمودارِ ون ────────────────────────────────────────────────────────── + * ناحیه‌ها با نامِ مجموعه‌هایی که در آن‌اند: «A» فقط A، «AB» اشتراکِ A و B (و نه + * C)، «ABC»، و «out» بیرونِ همه. سایه با برشِ تو در تو (clipPath) و ماسکِ + * «نه-این-مجموعه» ساخته می‌شود، پس ناحیه‌ی سایه‌خورده دقیقاً همان ناحیه است. */ + +export function Venn({ spec }: { spec: VennSpec }) { + const uid = useId().replace(/:/g, ''); + const sets = spec.sets.slice(0, 3); + if (sets.length < 1) return null; + const H = sets.length === 3 ? 250 : 190; + const R = sets.length === 3 ? 62 : 70; + const centers: [number, number][] = sets.length === 1 ? [[W / 2, 95]] + : sets.length === 2 ? [[W / 2 - 45, 95], [W / 2 + 45, 95]] + : [[W / 2 - 40, 95], [W / 2 + 40, 95], [W / 2, 160]]; + const ids = sets.map((_, i) => `v${uid}-${i}`); + const keyOf = (inside: number[]) => inside.map((i) => sets[i]).join(''); + // مرکزِ تقریبیِ هر ناحیه برای نوشتنِ اعضا + const regionCenter = (code: string): [number, number] => { + if (code === 'out') return [22, H - 16]; + const inside = sets.map((s, i) => (code.includes(s) ? i : -1)).filter((i) => i >= 0); + const all = centers.map((_, i) => i); + const cxIn = inside.reduce((a, i) => a + centers[i][0], 0) / inside.length, cyIn = inside.reduce((a, i) => a + centers[i][1], 0) / inside.length; + const outside = all.filter((i) => !inside.includes(i)); + if (!outside.length || inside.length > 1) { + if (inside.length === sets.length) return [cxIn, cyIn]; + const o = outside.length ? centers[outside[0]] : [cxIn, cyIn]; + return [cxIn + (cxIn - o[0]) * 0.18, cyIn + (cyIn - o[1]) * 0.18]; + } + const ox = outside.reduce((a, i) => a + centers[i][0], 0) / outside.length, oy = outside.reduce((a, i) => a + centers[i][1], 0) / outside.length; + const dx = cxIn - ox, dy = cyIn - oy, n = Math.hypot(dx, dy) || 1; + return [cxIn + (dx / n) * R * 0.42, cyIn + (dy / n) * R * 0.42]; + }; + const shaded = (spec.shade ?? []).filter((c) => c === 'out' || [...c].every((ch) => sets.includes(ch))); + + const shadeRegion = (code: string, idx: number) => { + const fill = `${COLORS.orange}66`; + if (code === 'out') { + return ( + + {centers.map((c, i) => )} + + + ); + } + const inside = sets.map((s, i) => (code.includes(s) ? i : -1)).filter((i) => i >= 0); + const outside = sets.map((_, i) => i).filter((i) => !inside.includes(i)); + const mid = `${ids[0]}-m${idx}`; + let node = ( + + {outside.length > 0 && {outside.map((i) => )}} + + + ); + for (const i of inside) node = {node}; + return {node}; + }; + + return ( + + {centers.map((c, i) => )} + + U + {shaded.map(shadeRegion)} + {centers.map((c, i) => { + const col = COLORS[SERIES[i]]; + return ( + + + + + ); + })} + {Object.entries(spec.regions).map(([code, members]) => { + const valid = code === 'out' || [...code].every((ch) => sets.includes(ch)); + if (!valid || !members.length) return null; + const [x, y] = regionCenter(code === 'out' ? 'out' : keyOf(sets.map((s, i) => (code.includes(s) ? i : -1)).filter((i) => i >= 0))); + const lines = chunk(members.slice(0, 12), 3); + return lines.map((line, li) => ( + + )); + })} + + ); +} + +const chunk = (xs: T[], n: number): T[][] => Array.from({ length: Math.ceil(xs.length / n) }, (_, i) => xs.slice(i * n, i * n + n)); + +/* ─── نمودارِ پیکانی (تابع از A به B) ─────────────────────────────────────── */ + +export function Mapping({ spec }: { spec: MappingSpec }) { + const L = spec.left.slice(0, 10), Rt = spec.right.slice(0, 10); + const rows = Math.max(L.length, Rt.length, 1); + const H = 56 + rows * 28; + const yOf = (i: number, n: number) => 40 + ((i + 0.5) * (rows * 28)) / n; + // جهتِ نگاشت چپ به راست است، مثلِ کتاب (دامنه سمتِ چپ) + const lx = 78, rx = W - 78; + const col = COLORS.red; + return ( + + + {[[lx, L, spec.leftLabel, COLORS.blue], [rx, Rt, spec.rightLabel, COLORS.green]].map(([x, items, label, c]) => ( + + + {label && } + {(items as string[]).map((it, i) => ( + + ))} + + ))} + {spec.arrows.filter(([a, b]) => a < L.length && b < Rt.length).map(([a, b], i) => ( + + ))} + + ); +} + +/* ─── نمودارِ درختی (احتمال و شمارش) ──────────────────────────────────────── */ + +export function Tree({ spec }: { spec: TreeSpec }) { + interface Laid { node: TreeNode; x: number; y: number; depth: number; parent?: Laid } + const laid: Laid[] = []; + let leaf = 0; + const depthOf = (n: TreeNode): number => 1 + Math.max(0, ...(n.children ?? []).map(depthOf)); + const depth = Math.min(4, depthOf(spec.root)); + const levelH = 62; + const place = (n: TreeNode, d: number, parent?: Laid): Laid => { + const kids = d < 3 ? (n.children ?? []).slice(0, 6) : []; + const me: Laid = { node: n, x: 0, y: 24 + d * levelH, depth: d, parent }; + if (!kids.length) { me.x = leaf++; laid.push(me); return me; } + const placed = kids.map((c) => place(c, d + 1, me)); + me.x = (placed[0].x + placed[placed.length - 1].x) / 2; + laid.push(me); + return me; + }; + place(spec.root, 0); + const leaves = Math.max(1, leaf); + const H = 24 + (depth - 1) * levelH + 34; + const X = (x: number) => 24 + ((x + 0.5) / leaves) * (W - 48); + return ( + + {laid.filter((n) => n.parent).map((n, i) => { + const p = n.parent!; + return ( + + + {n.node.p && } + + ); + })} + {laid.map((n, i) => ( + + + {mathText(n.node.label).slice(0, 10)} + + ))} + + ); +} diff --git a/src/diagram/solid.tsx b/src/diagram/solid.tsx new file mode 100644 index 0000000..9038c16 --- /dev/null +++ b/src/diagram/solid.tsx @@ -0,0 +1,156 @@ +import type { ReactNode } from 'react'; +import type { SolidSpec } from './types'; +import { COLORS, INK, Label, mathText } from './svg'; + +/** + * حجم‌های هندسی، با تصویرِ مایلِ کتابِ درسی: وجهِ جلو واقعی، عمق با زاویه‌ی ۳۵° + * و نصفِ اندازه؛ یال‌های پنهان خط‌چین. اندازه‌ها (a، b، c، r، h) نسبتِ واقعی را + * نگه می‌دارند؛ برچسب‌ها همان متنی‌اند که مدل داده («۵ cm»). + */ +type XY = [number, number]; +const W = 300; +const H = 250; +const FILL = `${COLORS.blue}1c`; +const TOP = `${COLORS.blue}33`; +const SIDE = `${COLORS.blue}10`; +const HID = '5 4'; +const DEPTH = 0.5; +const ANG = (35 * Math.PI) / 180; +const SQUASH = 0.32; // دایره‌ی قاعده از نمای مایل + +export function Solid({ spec }: { spec: SolidSpec }) { + const d = spec.dims ?? {}; + const l = spec.labels ?? {}; + const els: ReactNode[] = []; + let key = 0; + const k = () => key++; + const edge = (a: XY, b: XY, hidden = false, col = INK) => + els.push(); + const face = (pts: XY[], fill: string) => els.push( p.join(',')).join(' L')} Z`} fill={fill} stroke="none" />); + const label = (x: number, y: number, text: string | undefined, col = COLORS.red) => { + if (text) els.push(); + }; + const dim = (a: XY, b: XY, text: string | undefined) => { + // خطِ اندازه: خط‌چینِ قرمز با برچسب وسطش + els.push(); + if (text) label((a[0] + b[0]) / 2 + 12, (a[1] + b[1]) / 2 + 4, text); + }; + + const shape = spec.shape; + if (shape === 'cube' || shape === 'cuboid') { + let a = d.a ?? 1, b = d.b ?? (shape === 'cube' ? a : 1), c = d.c ?? (shape === 'cube' ? a : 1); + if (shape === 'cube') b = c = a; + const ox = Math.cos(ANG) * DEPTH * b, oy = Math.sin(ANG) * DEPTH * b; + const s = Math.min(170 / (a + ox), 170 / (c + oy)); + const x0 = (W - (a + ox) * s) / 2, y0 = H - 30; + const f = (x: number, y: number, z: number): XY => [x0 + (x + z * Math.cos(ANG) * DEPTH) * s, y0 - (y + z * Math.sin(ANG) * DEPTH) * s]; + const [A, B, C, D] = [f(0, 0, 0), f(a, 0, 0), f(a, c, 0), f(0, c, 0)]; + const [E, F, G, Hh] = [f(0, 0, b), f(a, 0, b), f(a, c, b), f(0, c, b)]; + face([D, C, G, Hh], TOP); face([B, F, G, C], SIDE); face([A, B, C, D], FILL); + edge(A, E, true); edge(E, F, true); edge(E, Hh, true); + [[A, B], [B, C], [C, D], [D, A], [B, F], [F, G], [G, C], [G, Hh], [Hh, D]].forEach(([p, q]) => edge(p, q)); + label((A[0] + B[0]) / 2, A[1] + 17, l.a); + label(A[0] - 18, (A[1] + D[1]) / 2 + 4, l.c ?? (shape === 'cube' ? undefined : undefined)); + label((B[0] + F[0]) / 2 + 16, (B[1] + F[1]) / 2 + 10, l.b); + } else if (shape === 'prism' || shape === 'pyramid') { + const n = Math.max(3, Math.min(8, Math.round(d.n ?? (shape === 'pyramid' ? 4 : 3)))); + const r = d.r ?? 1, h = d.h ?? 1.6; + const s = Math.min(95 / r, 165 / h); + const cx = W / 2, by = H - 44; + const rot = n === 4 ? Math.PI / 4 : Math.PI / 2 + Math.PI / n; + const ang = Array.from({ length: n }, (_, i) => rot + (2 * Math.PI * i) / n); + // sin(t) > 0 یعنی پایینِ بیضی در تصویر = جلوی حجم، نزدیک به بیننده. + const base: XY[] = ang.map((t) => [cx + r * s * Math.cos(t), by + r * s * SQUASH * Math.sin(t)]); + const midAng = (i: number) => ang[i] + Math.PI / n; + const inr = r * Math.cos(Math.PI / n); + // دیده شدنِ وجهِ جانبیِ i (بینِ رأسِ i و i+1): عمودِ بیرونی رو به بیننده. + // نما کمی از بالاست (tan ≈ SQUASH)، پس وجهِ مایلِ هرم کمی زودتر دیده می‌شود. + const faceVisible = (i: number) => shape === 'prism' + ? Math.sin(midAng(i)) > 1e-9 + : h * Math.sin(midAng(i)) + inr * SQUASH > 1e-9; + const vertexVisible = (i: number) => faceVisible(i) || faceVisible((i - 1 + n) % n); + if (shape === 'prism') { + const top: XY[] = base.map((p) => [p[0], p[1] - h * s]); + face(top, TOP); + for (let i = 0; i < n; i++) if (faceVisible(i)) face([base[i], base[(i + 1) % n], top[(i + 1) % n], top[i]], FILL); + for (let i = 0; i < n; i++) { + const j = (i + 1) % n; + edge(base[i], base[j], !faceVisible(i)); + edge(top[i], top[j]); + edge(base[i], top[i], !vertexVisible(i)); + } + const right = base.reduce((m, p, i) => (p[0] > base[m][0] ? i : m), 0); + if (l.h) dim([top[right][0] + 16, top[right][1]], [base[right][0] + 16, base[right][1]], l.h); + } else { + const apex: XY = [cx, by - h * s]; + for (let i = 0; i < n; i++) if (faceVisible(i)) face([base[i], base[(i + 1) % n], apex], i % 2 ? FILL : SIDE); + for (let i = 0; i < n; i++) { + edge(base[i], base[(i + 1) % n], !faceVisible(i)); + edge(base[i], apex, !vertexVisible(i)); + } + if (l.h) { + edge(apex, [cx, by], true, COLORS.red); + els.push(); + label(cx + 16, (apex[1] + by) / 2, l.h); + } + } + // ضلعِ قاعده: پایین‌ترین یالِ جلو + if (l.a) { + let best = 0; + for (let i = 0; i < n; i++) if (base[i][1] + base[(i + 1) % n][1] > base[best][1] + base[(best + 1) % n][1]) best = i; + label((base[best][0] + base[(best + 1) % n][0]) / 2, (base[best][1] + base[(best + 1) % n][1]) / 2 + 17, l.a); + } + } else if (shape === 'cylinder' || shape === 'cone') { + const r = d.r ?? 1, h = d.h ?? 2; + const s = Math.min(110 / r, 170 / (h + r * SQUASH * 2)); + const cx = W / 2, by = H - 36, rx = r * s, ry = r * s * SQUASH; + const topY = by - h * s; + const halfEllipse = (cy: number, front: boolean) => `M${cx - rx},${cy} A${rx},${ry} 0 0 ${front ? 0 : 1} ${cx + rx},${cy}`; + if (shape === 'cylinder') { + els.push(); + els.push(); + edge([cx - rx, topY], [cx - rx, by]); edge([cx + rx, topY], [cx + rx, by]); + els.push(); + els.push(); + if (l.r) { dim([cx, topY], [cx + rx, topY], undefined); els.push(); label(cx + rx / 2, topY - 8, l.r); } + if (l.h) dim([cx + rx + 16, topY], [cx + rx + 16, by], l.h); + } else { + const apex: XY = [cx, topY]; + // مماس از رأس بر بیضیِ قاعده: نقطه‌ی تماس جایی است که sin t = −ry / ارتفاعِ تصویر + // (از صفر شدنِ ضربِ خارجیِ «رأس→نقطه» در «مشتقِ بیضی» درمی‌آید). + const st = ry / Math.max(1e-6, by - topY), ct = Math.sqrt(Math.max(0, 1 - st * st)); + const tl: XY = [cx - rx * ct, by - ry * st], tr: XY = [cx + rx * ct, by - ry * st]; + els.push(); + els.push(); + els.push(); + edge(apex, tl); edge(apex, tr); + if (l.h) { + edge(apex, [cx, by], true, COLORS.red); + els.push(); + label(cx + 22, (topY + by) / 2 - 10, l.h); + } + if (l.r) { dim([cx, by], [cx + rx, by], undefined); label(cx + rx / 2, by + ry + 16, l.r); } + } + } else if (shape === 'sphere' || shape === 'hemisphere') { + const r = d.r ?? 1; + const R = 92, cx = W / 2, cy = shape === 'sphere' ? H / 2 + 4 : H - 70, ry = R * SQUASH; + if (shape === 'sphere') { + els.push(); + els.push(); + els.push(); + } else { + els.push(); + els.push(); + els.push(); + } + els.push(); + if (l.r) { dim([cx, cy], [cx + R, cy], undefined); label(cx + R / 2, cy - 8, l.r); } + void r; + } else return null; + + return ( + + {els} + + ); +} diff --git a/src/diagram/svg.tsx b/src/diagram/svg.tsx new file mode 100644 index 0000000..872dddc --- /dev/null +++ b/src/diagram/svg.tsx @@ -0,0 +1,143 @@ +import type { ReactNode } from 'react'; +import type { ColorName } from './types'; +import { toFa } from '@/lib/format'; + +/** + * رنگ‌ها و قطعه‌های مشترکِ همه‌ی شکل‌ها. + * + * رنگ‌ها معنا دارند و در همه‌ی شکل‌ها یکی‌اند: آبی شکلِ اصلی، قرمز زاویه و چیزی که + * باید دیده شود، سبز جواب، نارنجی/بنفش سری‌های دوم و سوم. مدل فقط نامِ رنگ را + * می‌دهد، نه کدِ hex، تا شکل‌ها همیشه با رنگ‌های اپ بخوانند. + */ +export const COLORS: Record = { + 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 ( + + {children} + + ); +} + +/** سرِ فلش؛ هر رنگ یک marker (SVG رنگِ marker را از خط به ارث نمی‌برد). */ +export function Arrowheads({ ids }: { ids: string[] }) { + return ( + + {[...new Set(ids)].map((c) => ( + + + + ))} + + ); +} +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 ( + + {num} + + {den} + + ); +} + +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)}`; +} diff --git a/src/diagram/types.ts b/src/diagram/types.ts new file mode 100644 index 0000000..6c7b08f --- /dev/null +++ b/src/diagram/types.ts @@ -0,0 +1,111 @@ +/** + * شِمای شکلِ دستیار. همان قراردادی که بک‌اند (rag/services/diagram.py) اعتبارسنجی + * می‌کند و مدل طبقِ قاعده‌ی پرامپت می‌نویسد. هر تغییر اینجا، آنجا هم لازم است. + * + * اصل: مدل «چه» را می‌گوید (نقطه‌ی وسط، پای عمود، ریشه‌های تابع)، کدِ اپ «کجا» را + * حساب می‌کند. هیچ مختصاتی که بشود حساب کرد از مدل گرفته نمی‌شود. + */ + +export type ColorName = 'blue' | 'red' | 'green' | 'orange' | 'purple' | 'teal' | 'pink' | 'gray' | 'black' | 'yellow'; + +export type Ref = string | [number, number]; + +/** تعریفِ نقطه: مختصات، یا ساختنی از نقطه‌های دیگر. */ +export type PointDef = + | [number, number] + | { polar: [number, number]; from?: Ref } + | { mid: [Ref, Ref] } + | { foot: [Ref, Ref, Ref] } + | { intersect: [[Ref, Ref], [Ref, Ref]] } + | { along: [Ref, Ref, number] } + | { fx: [string, number] }; + +interface Style { + color?: ColorName; + dashed?: boolean; + label?: string; +} + +export type Transform = + | { rotate: { center: Ref; deg: number } } + | { reflect: 'x' | 'y' | 'y=x' | [Ref, Ref] } + | { translate: [number, number] } + | { scale: { center: Ref; k: number } }; + +export type FunctionMark = 'roots' | 'vertex' | 'extrema' | 'yIntercept' | 'axis'; + +export type PlaneItem = + | ({ type: 'point'; point: Ref; open?: boolean } & Style) + | ({ type: 'segment'; from: Ref; to: Ref; ticks?: number } & Style) + | ({ type: 'arrow' | 'vector'; from: Ref; to: Ref } & Style) + | ({ type: 'line'; through?: [Ref, Ref]; /** ax + by = c */ eq?: [number, number, number] } & Style) + | ({ type: 'ray'; from: Ref; through: Ref } & Style) + | ({ type: 'vline'; x: number } & Style) + | ({ type: 'hline'; y: number } & Style) + | ({ type: 'circle'; center: Ref; r?: number; through?: Ref; fill?: boolean } & Style) + | ({ type: 'arc' | 'sector'; center: Ref; r: number; start: number; end: number } & Style) + | ({ type: 'angle'; vertex: Ref; from: Ref; to: Ref; arcs?: number } & Style) + | ({ type: 'rightAngle'; vertex: Ref; from: Ref; to: Ref } & Style) + | ({ type: 'polygon'; points: Ref[]; fill?: boolean } & Style) + | ({ type: 'function'; expr: string; from: number; to: number; marks?: FunctionMark[] } & Style) + | ({ type: 'piecewise'; pieces: { expr: string; from: number; to: number; closedFrom?: boolean; closedTo?: boolean }[] } & Style) + | ({ type: 'tangent'; expr: string; at: number } & Style) + | ({ type: 'area'; expr: string; expr2?: string; from: number; to: number } & Style) + | ({ type: 'region'; expr: string; side: 'above' | 'below'; from?: number; to?: number; strict?: boolean } & Style) + | ({ type: 'image'; of: Ref[]; transform: Transform; names?: string[] } & Style) + | { type: 'text'; at: [number, number]; text: string; color?: ColorName }; + +export interface PlaneSpec { + kind: 'plane'; + title: string; + axes: boolean; + grid?: boolean; + /** محورِ x با مضرب‌های π/2 */ + trig?: boolean; + xRange?: [number, number]; + yRange?: [number, number]; + points: Record; + items: PlaneItem[]; +} + +export interface NumberLineSpec { + kind: 'numberline'; + title: string; + min: number; + max: number; + step?: number; + /** برچسبِ تیک‌ها به‌صورتِ کسر (۱/۴، ۲/۴، …) */ + fractions?: boolean; + points: { at: number; label: string; open?: boolean; color?: ColorName }[]; + jumps: { from: number; to: number; label: string }[]; + intervals?: { from: number | null; to: number | null; closedFrom?: boolean; closedTo?: boolean; color?: ColorName; label?: string }[]; +} + +export interface FractionSpec { + kind: 'fraction'; title: string; shape: 'circle' | 'rect'; parts: number; shaded: number; + /** واحدهای کاملِ عددِ مخلوط (دو و سه‌چهارم → ۲) */ + wholes?: number; + /** مقایسه‌ی چند کسر با نوارهای هم‌اندازه */ + compare?: { parts: number; shaded: number; label?: string }[]; +} +export interface GridSpec { kind: 'grid'; title: string; rows: number; cols: number; shaded: number | [number, number][]; color?: ColorName } +export interface GroupsSpec { kind: 'groups'; title: string; groups: number; each: number; item?: 'circle' | 'square' | 'star'; label?: string } +export interface ClockSpec { kind: 'clock'; title: string; hour: number; minute: number } +export interface ChartSpec { kind: 'bar' | 'linechart' | 'pie'; title: string; labels: string[]; values: number[]; unit?: string } +export interface TableSpec { kind: 'table'; title: string; headers: string[]; rows: string[][] } +export interface VennSpec { kind: 'venn'; title: string; sets: string[]; regions: Record; shade?: string[]; /** نامِ نمایشیِ مجموعه‌ها («والیبال»)؛ بی‌آن همان A، B، C */ names?: string[] } +export interface MappingSpec { kind: 'mapping'; title: string; left: string[]; right: string[]; arrows: [number, number][]; leftLabel?: string; rightLabel?: string } +export interface TreeNode { label: string; p?: string; children?: TreeNode[] } +export interface TreeSpec { kind: 'tree'; title: string; root: TreeNode } +export interface SolidSpec { + kind: 'solid'; + title: string; + shape: 'cube' | 'cuboid' | 'prism' | 'pyramid' | 'cylinder' | 'cone' | 'sphere' | 'hemisphere'; + /** a پهنا، b عمق، c ارتفاع (مکعب‌مستطیل)؛ n تعدادِ ضلعِ قاعده؛ r شعاع؛ h ارتفاع */ + dims?: { a?: number; b?: number; c?: number; n?: number; r?: number; h?: number }; + labels?: { a?: string; b?: string; c?: string; r?: string; h?: string }; +} + +export type DiagramSpec = + | PlaneSpec | NumberLineSpec | FractionSpec | GridSpec | GroupsSpec | ClockSpec + | ChartSpec | TableSpec | VennSpec | MappingSpec | TreeSpec | SolidSpec; diff --git a/src/lib/MathText.tsx b/src/lib/MathText.tsx new file mode 100644 index 0000000..32a11d5 --- /dev/null +++ b/src/lib/MathText.tsx @@ -0,0 +1,43 @@ +import { Fragment } from 'react'; + +/** + * متنِ فارسی که ریاضی لایش است («عدد −۱ می‌شود»، «رأس (۱، −۴) است»). + * + * رقمِ فارسی در الگوریتمِ جهتِ یونیکد «عددِ عربی» است و در متنِ راست‌به‌چپ علامت‌های + * کنارش را جابه‌جا می‌کند: «−۱» به‌صورتِ «۱−» و «(۱، −۴)» به‌صورتِ «(−۴ ،۱)» دیده + * می‌شد. هر تکه‌ای که حرفِ فارسی ندارد ولی رقم دارد، اینجا با bidi-override چپ‌به‌راست + * قفل می‌شود؛ جمله‌ی فارسی دورش دست نمی‌خورد. + * + * آزموده روی Chrome (دسکتاپ و موبایلِ شبیه‌سازی‌شده)؛ روی Safariِ واقعیِ آیفون دیده نشده. + */ +// حروف، اِعراب (کسره‌ی «خودِ»)، و نیم‌فاصله؛ رقم‌های ۰–۹ عمداً بیرون‌اند. +const LETTER = '\\u0620-\\u065F\\u066E-\\u06D3\\u06FA-\\u06FF\\u200C'; +// تکه‌ای بدونِ حرفِ فارسی و بدونِ خطِ تازه که دستِ‌کم یک رقم دارد +const RUN = new RegExp(`[^${LETTER}\\n]*[0-9۰-۹][^${LETTER}\\n]*`, 'g'); +// نشانه‌هایی که مالِ جمله‌ی فارسی‌اند، نه ریاضی: از دو سرِ تکه بیرون می‌مانند +const EDGE_START = /^[\s،؛:!؟«»"'.=]+/; +const EDGE_END = /[\s،؛:!؟«»"'.=]+$/; +const LTR = { unicodeBidi: 'bidi-override', direction: 'ltr' } as const; + +export function MathText({ text }: { text: string }) { + const out: React.ReactNode[] = []; + let last = 0, i = 0; + for (const m of text.matchAll(RUN)) { + const start = m.index ?? 0; + let run = m[0]; + // شماره‌ی مرحله در سرِ خط («۴) …») مالِ جمله‌ی فارسی است و باید سمتِ راست بماند. + const atLineStart = start === 0 || text[start - 1] === '\n'; + const step = atLineStart ? /^\s*[0-9۰-۹]{1,2}\)\s*/.exec(run)?.[0] ?? '' : ''; + const lead = step + (EDGE_START.exec(run.slice(step.length))?.[0] ?? ''); + run = run.slice(lead.length); + const tail = EDGE_END.exec(run)?.[0] ?? ''; + run = run.slice(0, run.length - tail.length); + // «(۱، −۴)» باید کامل قفل شود، ولی پرانتزِ بازِ بی‌جفت در سرِ تکه مالِ جمله است. + if (!run || !/[0-9۰-۹]/.test(run)) continue; + out.push(text.slice(last, start + lead.length)); + out.push({run}); + last = start + lead.length + run.length; + } + out.push(text.slice(last)); + return <>{out.map((n, j) => (typeof n === 'string' ? {n} : n))}; +} diff --git a/src/main.tsx b/src/main.tsx index b1e183f..f9df4ad 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,10 +1,20 @@ -import { StrictMode } from 'react'; +import { StrictMode, lazy, Suspense } from 'react'; import { createRoot } from 'react-dom/client'; import { App } from './app/App'; import './styles/index.css'; +// گالریِ شکل‌ها فقط در حالتِ توسعه؛ در build اصلاً وارد نمی‌شود (import.meta.env.DEV ثابت است). +const DiagramGallery = import.meta.env.DEV + ? lazy(() => import('./dev/DiagramGallery').then((m) => ({ default: m.DiagramGallery }))) + : null; +const showGallery = DiagramGallery && new URLSearchParams(window.location.search).has('gallery'); + createRoot(document.getElementById('root')!).render( - + {showGallery && DiagramGallery ? ( + + ) : ( + + )} , ); diff --git a/src/screens/AskScreen.tsx b/src/screens/AskScreen.tsx index c3620ae..56a27ac 100644 --- a/src/screens/AskScreen.tsx +++ b/src/screens/AskScreen.tsx @@ -7,7 +7,8 @@ import { askStore, useAskState, type ChatMessage } from '@/state/askStore'; import { SeyarMascot } from '@/mascots/SeyarMascot'; import { useToast } from '@/components/Toast'; import { Screen, ScreenHeader } from '@/components/ui'; -import { Diagram } from '@/components/Diagram'; +import { Diagram } from '@/diagram'; +import { MathText } from '@/lib/MathText'; import { friendlyError } from '@/lib/errors'; import { toFa } from '@/lib/format'; @@ -222,7 +223,7 @@ function Message({ }) { const toast = useToast(); - if (message.role === 'user') return {message.text}; + if (message.role === 'user') return ; if (!message.text) { return ( @@ -255,7 +256,7 @@ function Message({ return (
: undefined}> - {message.text} +
{message.failedQuestion ? ( diff --git a/src/screens/FamilyScreen.tsx b/src/screens/FamilyScreen.tsx index 9fce3a3..2f8ec1d 100644 --- a/src/screens/FamilyScreen.tsx +++ b/src/screens/FamilyScreen.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { BookOpen, Flame, Link2, LogOut, Star, Trophy, UserPlus } from 'lucide-react'; import { useNav } from '@/nav/Navigator'; +import { useAppData } from '@/state/AppData'; import { api, ApiError, type FamilyLink, type FamilyWeeklyReport } from '@/api'; import { useAsync } from '@/lib/useAsync'; import { useToast } from '@/components/Toast'; @@ -15,6 +16,11 @@ import { digitsOnly, formatJalaliDate, toFa } from '@/lib/format'; */ export function FamilyScreen({ onSignedOut }: { onSignedOut: () => void }) { const nav = useNav(); + const { profile } = useAppData(); + // دانش‌آموز (پایه دارد) پیش از اتصال فقط «کدِ دعوت» را می‌بیند؛ فرمِ والد برای او پشتِ یک + // پیوند است. قبلاً هر دو فرم با هم بود و بچه نمی‌دانست کدام مالِ اوست. والد پایه ندارد. + const isStudent = profile.gradeId !== null; + const [showParentForm, setShowParentForm] = useState(false); // GET /family/link نمی‌گوید کاربر در این اتصال والد است یا فرزند؛ گزارشِ // هفتگی فقط برای والد جواب می‌دهد و برای فرزند ۴۰۴ است، پس نقش از همان درمی‌آید. const state = useAsync(async () => { @@ -34,7 +40,7 @@ export function FamilyScreen({ onSignedOut }: { onSignedOut: () => void }) { return ( - + {state.loading ? ( @@ -44,8 +50,14 @@ export function FamilyScreen({ onSignedOut }: { onSignedOut: () => void }) { <> {data?.link && } {data?.report && } - {data?.role !== 'child' && } - {!data?.role && } + {!data?.role && isStudent && } + {data?.role !== 'child' && (!isStudent || showParentForm || data?.role === 'parent' || data?.link?.status === 'pending') && } + {!data?.role && !isStudent && } + {!data?.role && isStudent && !showParentForm && data?.link?.status !== 'pending' && ( + + )} )} {/* والد بدونِ پایه نوارِ پایین را ندارد؛ خروج باید همین‌جا باشد. */}