Files
MathApp_frontend/src/diagram/plane.tsx
AliReza 0957ef476b lighter first load, math reads left-to-right in quizzes, tickets from the server, readable separators
- Every screen but home is its own chunk and preloads when the browser is
  idle; React and motion get their own long-cached chunks. The app entry
  goes from 505 kB (160 kB gzip) to 85 kB (27 kB gzip).
- Quiz prompts, options and lesson examples go through MathText, so
  '10 + 3 - 7 - 2' reads from the left; an unpaired bracket at the edge of
  a math run stays with the Persian sentence.
- Support lists tickets from GET /support/tickets instead of this
  browser's localStorage; a ticket's messages load when it is opened.
- '·' next to a Persian zero read as another digit ('فصلِ ۱ · ۰' looked
  like ۱۰۰); a thin bar separates them now. Notification bodies show
  Persian digits.
- A root or vertex the model already marked with coordinates is no longer
  labelled a second time on top of it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 21:18:49 +03:30

797 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { 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<string, XY>();
const resolving = new Set<string>();
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 <Label x={x} y={y} color={fill} size={size} anchor={anchor}>{mathText(text)}</Label>;
return (
<g>
<Label x={x} y={y - size * 0.55} color={fill} size={size} anchor={anchor}>{parts[0]}</Label>
<text x={x} y={y + size * 0.75} fontSize={size - 0.5} fontWeight={700} fill={fill} textAnchor={anchor} {...bidi(mathText(parts[1]))}
stroke={PAPER} strokeWidth={3} paintOrder="stroke">{mathText(parts[1])}</text>
</g>
);
}
/* ─── خودِ شکل ───────────────────────────────────────────────────────────── */
/**
* قطعه‌هایی که به قطعه‌های ساده‌تر باز می‌شوند، پیش از هر حسابی:
* 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<string, PointDef> = { ...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<string> {
const out = new Set<string>();
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<PlaneItem, { type: 'image' }>; 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<PlaneItem, Curve[]>();
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<string>([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(
<g key={k()} stroke={GRID} strokeWidth={1}>
{xticks.map((t) => <line key={`gx${t}`} x1={X(t)} x2={X(t)} y1={Y(y0)} y2={Y(y1)} />)}
{yticks.map((t) => <line key={`gy${t}`} y1={Y(t)} y2={Y(t)} x1={X(x0)} x2={X(x1)} />)}
</g>,
);
}
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(
<g key={k()} stroke={SOFT} strokeWidth={1.4}>
<line x1={X(x0)} y1={ay} x2={X(x1) + 6} y2={ay} markerEnd={`url(#${arrowId(SOFT)})`} />
<line x1={ax} y1={Y(y0)} x2={ax} y2={Y(y1) - 6} markerEnd={`url(#${arrowId(SOFT)})`} />
</g>,
<g key={k()} fill={SOFT} fontSize={9.5} fontWeight={600}>
{xticks.filter((t) => Math.abs(t) > 1e-9).map((t) => (
<g key={`tx${t}`}>
<line x1={X(t)} x2={X(t)} y1={ay - 3} y2={ay + 3} stroke={SOFT} />
<text x={X(t)} y={ay + 13} textAnchor="middle">{trig ? piLabel(t) : fa(t)}</text>
</g>
))}
{yticks.filter((t) => Math.abs(t) > 1e-9).map((t) => (
<g key={`ty${t}`}>
<line x1={ax - 3} x2={ax + 3} y1={Y(t)} y2={Y(t)} stroke={SOFT} />
<text x={ax - 6} y={Y(t) + 3.5} textAnchor="end">{fa(t)}</text>
</g>
))}
{x0 <= 0 && x1 >= 0 && y0 <= 0 && y1 >= 0 && <text x={ax - 6} y={ay + 13} textAnchor="end">۰</text>}
<text x={X(x1) + 2} y={ay - 7} textAnchor="end" fontStyle="italic">x</text>
<text x={ax + 7} y={Y(y1) + 4} fontStyle="italic">y</text>
</g>,
);
}
/* ── تکه‌ها ── */
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(<ellipse key={k()} cx={X(c[0])} cy={Y(c[1])} rx={r * sx} ry={r * sy} fill={item.fill ? `${col}1f` : 'none'} stroke={col} strokeWidth={2.2} strokeDasharray={item.dashed ? '6 5' : undefined} />);
if (item.label) layers.labels.push(<RichLabel key={k()} x={X(c[0] + r * 0.72)} y={Y(c[1] + r * 0.72) - 4} text={item.label} fill={col} />);
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(<path key={k()} d={d} fill={item.type === 'sector' ? `${col}33` : 'none'} stroke={col} strokeWidth={item.type === 'arc' ? 3 : 2} strokeDasharray={item.dashed ? '6 5' : undefined} />);
if (item.label) {
const m = (a0 + a1) / 2, rr = item.type === 'arc' ? item.r * 1.16 : item.r * 0.6;
layers.labels.push(<RichLabel key={k()} x={X(c[0] + rr * Math.cos(m))} y={Y(c[1] + rr * Math.sin(m)) + 4} text={item.label} fill={col} />);
}
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(<path key={k()} d={`${path(pts)} Z`} fill={item.fill === false ? 'none' : `${col}1a`} stroke={item.color ? col : INK} strokeWidth={2.2} strokeLinejoin="round" strokeDasharray={item.dashed ? '6 5' : undefined} />);
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(<RichLabel key={k()} x={X(cx)} y={Y(cy) + 4} text={item.label} fill={col} />);
}
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(<line key={k()} x1={ax} y1={ay} x2={bx} y2={by} stroke={col} strokeWidth={isArrow ? 2.4 : 2.2} strokeLinecap="round"
strokeDasharray={item.dashed ? '6 5' : undefined} markerEnd={isArrow ? `url(#${arrowId(col)})` : undefined} />);
if (item.type === 'segment' && item.ticks) layers.mid.push(<Ticks key={k()} a={[ax, ay]} b={[bx, by]} n={item.ticks} col={col} />);
if (item.label) {
const [lx, ly, anchor] = sideOffset([ax, ay], [bx, by], P(centroid));
layers.labels.push(<RichLabel key={k()} x={lx} y={ly} anchor={anchor} text={item.label} fill={item.color ? col : COLORS.blue} />);
}
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(<line key={k()} clipPath={clip} x1={X(s[0])} y1={Y(s[1])} x2={X(e[0])} y2={Y(e[1])} stroke={col} strokeWidth={2} strokeDasharray={item.dashed ? '6 5' : undefined} />);
if (item.label) {
const lp = edgePoint(a, b, x0, x1, y0, y1);
layers.labels.push(<RichLabel key={k()} x={X(lp[0])} y={Y(lp[1]) - 6} text={item.label} fill={col} />);
}
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(<line key={k()} x1={X(item.x)} x2={X(item.x)} y1={Y(y0)} y2={Y(y1)} stroke={col} strokeWidth={1.8} strokeDasharray={dash} />);
if (item.label) layers.labels.push(<RichLabel key={k()} x={X(item.x) + 4} y={Y(y1) + 12} text={item.label} fill={col} />);
} else {
layers.mid.push(<line key={k()} y1={Y(item.y)} y2={Y(item.y)} x1={X(x0)} x2={X(x1)} stroke={col} strokeWidth={1.8} strokeDasharray={dash} />);
if (item.label) layers.labels.push(<RichLabel key={k()} x={X(x0) + 30} y={Y(item.y) - 7} text={item.label} fill={col} />);
}
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(
<path key={k()} clipPath={clip} d={path(run)} fill="none" stroke={col} strokeWidth={2.6} strokeLinejoin="round" strokeLinecap="round" strokeDasharray={item.dashed ? '7 5' : undefined} />,
)));
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(<circle key={k()} cx={X(x)} cy={Y(y)} r={4} fill={closed ? col : '#fff'} stroke={col} strokeWidth={2} />);
});
});
}
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(<RichLabel key={k()} x={Math.min(W - 44, Math.max(44, X(at[0]) - 30))} y={Math.max(14, Math.min(H - 8, Y(at[1]) + (slope < 0 ? 16 : -10)))} text={item.label} fill={col} />);
}
}
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(<line key={k()} clipPath={clip} x1={X(item.at - span)} y1={Y(y - m * span)} x2={X(item.at + span)} y2={Y(y + m * span)} stroke={col} strokeWidth={2} strokeDasharray={item.dashed ? '6 5' : undefined} />);
layers.points.push(<circle key={k()} cx={X(item.at)} cy={Y(y)} r={4.2} fill={col} stroke="#fff" strokeWidth={1.5} />);
const text = item.label ?? `شیب = ${fa(m)}`;
layers.labels.push(<RichLabel key={k()} x={Math.min(W - 44, X(item.at) + 36)} y={Y(y) + (m > 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(<path key={k()} clipPath={clip} d={`${path([...top, ...bottom])} Z`} fill={`${col}38`} stroke="none" />);
if (item.label) {
const mid = top[Math.floor(top.length / 2)];
layers.labels.push(<RichLabel key={k()} x={X(mid[0])} y={(Y(mid[1]) + Y(cs[1] ? cs[1].f(mid[0]) : 0)) / 2 + 4} text={item.label} fill={col} />);
}
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(<path key={k()} clipPath={clip} d={`${path(poly)} Z`} fill={`${col}26`} stroke="none" />);
layers.curves.push(<path key={k()} clipPath={clip} d={path(run)} fill="none" stroke={col} strokeWidth={2.2} strokeDasharray={item.strict ? '6 5' : undefined} />);
});
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(<RichLabel key={k()} x={X(best)} y={Y(yl)} text={item.label} fill={col} />);
}
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(<path key={k()} d={`M${p1.join(',')} L${p2.join(',')} L${p3.join(',')}`} fill={`${col}22`} stroke={col} strokeWidth={1.8} />);
} 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(
<path key={k()} d={i === 0 ? `M${vx},${vy} L${s.join(',')} A${rr},${rr} 0 0 ${d > 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(<RichLabel key={k()} x={vx + lr * Math.cos(m)} y={vy + lr * Math.sin(m) + 4} text={item.label} fill={col} />);
}
break;
}
case 'text': {
layers.labels.push(<RichLabel key={k()} x={X(item.at[0])} y={Y(item.at[1])} text={item.text} fill={color(item.color, INK)} />);
break;
}
default: break;
}
}
/* ── تصویرِ تبدیل‌ها ── */
for (const im of images) {
const col = color(im.item.color, COLORS.purple);
if (im.pts.length >= 3) layers.fill.push(<path key={k()} d={`${path(im.pts)} Z`} fill={`${col}14`} stroke={col} strokeWidth={2.2} strokeDasharray={im.item.dashed === false ? undefined : '6 4'} strokeLinejoin="round" />);
else if (im.pts.length === 2) layers.mid.push(<line key={k()} x1={X(im.pts[0][0])} y1={Y(im.pts[0][1])} x2={X(im.pts[1][0])} y2={Y(im.pts[1][1])} stroke={col} strokeWidth={2.2} strokeDasharray="6 4" />);
im.pts.forEach((p, i) => {
layers.points.push(<circle key={k()} cx={X(p[0])} cy={Y(p[1])} r={3.4} fill={col} />);
if (im.names[i]) { const [lx, ly] = away(p); layers.labels.push(<Label key={k()} x={lx} y={ly} color={col} weight={800}>{im.names[i]}</Label>); }
});
}
/* ── نقطه‌های ویژه‌ی نمودار ── */
for (const x of axisLines) {
layers.mid.push(<line key={k()} x1={X(x)} x2={X(x)} y1={Y(y0)} y2={Y(y1)} stroke={COLORS.gray} strokeWidth={1.6} strokeDasharray="5 4" />);
layers.labels.push(<Label key={k()} x={X(x)} y={Y(y1) + 12} color={COLORS.gray} size={10}>{`x = ${fa(x)}`}</Label>);
}
const markName: Record<string, string> = { 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<string, number>();
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(<circle key={k()} cx={px} cy={py} r={4.3} fill={m.color} stroke="#fff" strokeWidth={1.6} />);
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(
<g key={k()}>
{name && <Label x={px} y={below ? ly + 12 : ly - 12} color={m.color} size={10}>{name}</Label>}
<text x={px} y={ly} fontSize={10} fontWeight={800} fill={m.color} textAnchor="middle" {...bidi(coord)} stroke={PAPER} strokeWidth={3} paintOrder="stroke">{coord}</text>
</g>,
);
}
/* ── نقطه‌ها ── */
const explicit = new Map<string, Extract<PlaneItem, { type: 'point' }>>();
const loosePoints: Extract<PlaneItem, { type: 'point' }>[] = [];
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(<circle key={k()} cx={px} cy={py} r={open ? 4 : 3.8} fill={open ? '#fff' : col} stroke={open ? col : '#fff'} strokeWidth={open ? 2 : 1.4} />);
if (label) { const [lx, ly] = away(xy, 14); layers.labels.push(<RichLabel key={k()} x={lx} y={ly} text={label} fill={col} />); }
};
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 (
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[340px]" role="img" aria-label={spec.title || 'شکل'}>
<defs>
<clipPath id={clipId}><rect x={X(x0)} y={Y(y1)} width={(x1 - x0) * sx} height={(y1 - y0) * sy} /></clipPath>
</defs>
<Arrowheads ids={[...arrowColors]} />
{layers.back}{layers.fill}{layers.mid}{layers.curves}{layers.marks}{layers.points}{layers.labels}
</svg>
);
}
/**
* دو نقطه از خط: یا through، یا از معادله‌ی ax + by = c (ضریب‌ها از صورتِ مسئله؛
* شیب را کد حساب می‌کند، چون مدل در بازنویسیِ 3x + 5y = 4 به y = … علامت را گم کرده بود).
*/
function lineEnds(item: Extract<PlaneItem, { type: 'line' }>, 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 (
<g stroke={col} strokeWidth={2}>
{Array.from({ length: count }, (_, i) => {
const o = (i - (count - 1) / 2) * 4.5;
const cx = mx + ux * o, cy = my + uy * o;
return <line key={i} x1={cx - nx * 6} y1={cy - ny * 6} x2={cx + nx * 6} y2={cy + ny * 6} />;
})}
</g>
);
}
/**
* جای برچسبِ ضلع: وسطِ پاره‌خط، به سمتِ بیرونِ شکل. برای خطِ تقریباً عمودی برچسب
* از کنارِ خط شروع می‌شود (نه وسط‌چین)، وگرنه متنِ بلند روی خودِ خط می‌افتد.
*/
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];
}