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 { expandQuad, expandTriangle, type Layout } from './shapes'; 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 ('translate' in def) { const o = ref(def.from); return o ? [o[0] + def.translate[0], o[1] + def.translate[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])} ); } /* ─── خودِ شکل ───────────────────────────────────────────────────────────── */ /** * قطعه‌هایی که به قطعه‌های ساده‌تر باز می‌شوند، پیش از هر حسابی: * regularPolygon → نقطه‌های polar با زاویه‌های مساوی + polygon (مدل مختصاتِ رأسِ پنج‌ضلعیِ * منتظم را خودش حدس زده بود و شکل منتظم نشد) * exteriorAngle → نقطه‌ی پنهان روی امتدادِ ضلع + پاره‌خطِ خط‌چین + angle (مدل با angle همیشه * زاویه‌ی داخلی را می‌کشید) * triangle، quad → رأس‌ها از روی ضلع و زاویه و ارتفاعِ مسئله (shapes.ts) */ const COMPOUND = new Set(['regularPolygon', 'exteriorAngle', 'triangle', 'quad']); function expand(spec: PlaneSpec): PlaneSpec { if (!spec.items.some((i) => COMPOUND.has(i.type))) return spec; const points: Record = { ...spec.points }; const items: PlaneItem[] = []; const layout: Layout = { placed: [] }; const taken = (n: string) => n in points || spec.items.some((i) => (i.type === 'triangle' || i.type === 'quad') && (i.names as string[]).includes(n)); spec.items.forEach((item, idx) => { if (item.type === 'triangle') { // داده‌ی متناقض (۳، ۴، ۱۰) مثلثی نمی‌سازد: فقط همین item حذف می‌شود items.push(...(expandTriangle(item, idx, points, taken, layout) ?? [])); } else if (item.type === 'quad') { items.push(...expandQuad(item, idx, points, layout)); } else if (item.type === 'regularPolygon') { const center = item.center ?? '_rc' + idx; if (!item.center) points['_rc' + idx] = [0, 0]; // پیش‌فرض: ضلعِ پایین افقی، مثلِ کتاب const start = item.start ?? -90 - 180 / item.n; const names = Array.from({ length: item.n }, (_, i) => item.names?.[i] ?? `_r${idx}_${i}`); names.forEach((name, i) => { points[name] = { polar: [item.r, start + (360 * i) / item.n], from: center }; }); items.push({ type: 'polygon', points: names, fill: item.fill, color: item.color, dashed: item.dashed, label: item.label }); } else if (item.type === 'exteriorAngle') { const ext = `_ext${idx}`; points[ext] = { along: [item.from, item.vertex, 1.7] }; items.push({ type: 'segment', from: item.vertex, to: ext, dashed: true, color: 'gray' }); items.push({ type: 'angle', vertex: item.vertex, from: ext, to: item.to, label: item.label, color: item.color }); } else items.push(item); }); return { ...spec, points, items }; } /** نام‌هایی که یک item یا تعریفِ نقطه‌ی دیگری به آن‌ها ارجاع داده. */ function referencedNames(spec: PlaneSpec): Set { const out = new Set(); const walk = (v: unknown) => { if (typeof v === 'string') { if (v in spec.points) out.add(v); } else if (Array.isArray(v)) v.forEach(walk); else if (v && typeof v === 'object') Object.values(v).forEach(walk); }; spec.items.forEach(walk); Object.values(spec.points).forEach(walk); return out; } export function Plane({ spec: raw }: { spec: PlaneSpec }) { const spec = expand(raw); // شناسه‌ی یکتا: چند شکل در یک گفتگو نباید 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 && !item.equation) { 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)); // اگر مدل خودش روی همان ریشه یا رأس نقطه گذاشته (نام‌دار یا با مختصات)، برچسبِ ما // تکرار نشود؛ وگرنه «(۳، ۰)» دو بار و روی هم نوشته می‌شد. const namedXY = [ ...named.values(), ...spec.items.flatMap((it) => (it.type === 'point' && typeof it.point !== 'string' ? [it.point as XY] : [])), ]; for (const m of markPts) { if (namedXY.some((q) => Math.abs(q[0] - m.xy[0]) < 1e-3 && Math.abs(q[1] - m.xy[1]) < 1e-3)) continue; 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)); // نقطه‌ای که هیچ تکه‌ای به آن ارجاع نداده رسم نمی‌شود (مدل هفت نقطه‌ی امتداد تعریف کرده // بود که هیچ‌جا به کار نرفتند و شکل را شلوغ کردند) — مگر شکل فقط از نقطه ساخته شده باشد. const used = referencedNames(spec); const onlyPoints = spec.items.length === 0; named.forEach((xy, name) => { if (name.startsWith('_') || imageNames.has(name)) return; if (!onlyPoints && !used.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] { const coef = item.equation ? parseLinear(item.equation) : item.eq ?? null; if (coef) { // ax + by + c = 0 const [a, b, c] = coef; 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]; } /** * «3x + 5y = 4»، «y = 12x − 16»، «2y = x + 1»، «x = 2.5» → [a, b, c] با ax + by + c = 0. * هر چیزِ غیرخطی (x²، sin) null است و خط کشیده نمی‌شود. */ export function parseLinear(src: string): [number, number, number] | null { const text = toLatin(src).replace(/\s+/g, '').replace(/[−–]/g, '-').replace(/\*/g, ''); const sides = text.split('='); if (sides.length !== 2) return null; const side = (t: string): [number, number, number] | null => { if (!t) return null; const terms = t.replace(/-/g, '+-').split('+').filter(Boolean); const acc: [number, number, number] = [0, 0, 0]; for (const term of terms) { const m = /^(-?)(\d*\.?\d*)(?:\/(\d+\.?\d*))?([xy]?)$/.exec(term); if (!m || (!m[2] && !m[4])) return null; let v = m[2] ? Number(m[2]) : 1; if (m[3]) v /= Number(m[3]); if (m[1]) v = -v; acc[m[4] === 'x' ? 0 : m[4] === 'y' ? 1 : 2] += v; } return acc; }; const l = side(sides[0]), r = side(sides[1]); if (!l || !r) return null; const out: [number, number, number] = [l[0] - r[0], l[1] - r[1], l[2] - r[2]]; return Math.abs(out[0]) + Math.abs(out[1]) > 1e-12 ? out : null; } const toLatin = (s: string) => s.replace(/[۰-۹]/g, (d) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(d))).replace(/[٠-٩]/g, (d) => String('٠١٢٣٤٥٦٧٨٩'.indexOf(d))); /** علامتِ تساویِ ضلع: یک، دو یا سه خطِ کوچک عمود بر وسطِ پاره‌خط. */ 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]; }