diff --git a/src/diagram/plane.tsx b/src/diagram/plane.tsx index 90cb50f..81f1f8e 100644 --- a/src/diagram/plane.tsx +++ b/src/diagram/plane.tsx @@ -166,7 +166,51 @@ function RichLabel({ x, y, text, fill, size = 11, anchor = 'middle' }: { x: numb /* ─── خودِ شکل ───────────────────────────────────────────────────────────── */ -export function Plane({ spec }: { spec: PlaneSpec }) { +/** + * قطعه‌هایی که به قطعه‌های ساده‌تر باز می‌شوند، پیش از هر حسابی: + * regularPolygon → نقطه‌های polar با زاویه‌های مساوی + polygon (مدل مختصاتِ رأسِ پنج‌ضلعیِ + * منتظم را خودش حدس زده بود و شکل منتظم نشد) + * exteriorAngle → نقطه‌ی پنهان روی امتدادِ ضلع + پاره‌خطِ خط‌چین + angle (مدل با angle همیشه + * زاویه‌ی داخلی را می‌کشید) + */ +function expand(spec: PlaneSpec): PlaneSpec { + if (!spec.items.some((i) => i.type === 'regularPolygon' || i.type === 'exteriorAngle')) return spec; + const points: Record = { ...spec.points }; + const items: PlaneItem[] = []; + spec.items.forEach((item, idx) => { + 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); @@ -248,7 +292,7 @@ export function Plane({ spec }: { spec: PlaneSpec }) { } 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 '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; @@ -578,7 +622,10 @@ export function Plane({ spec }: { spec: PlaneSpec }) { // بیش از چهار نقطه از یک نوع (ریشه‌های سینوس) فقط نقطه می‌گیرند، نه برچسب؛ وگرنه روی هم می‌افتند. 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()]; 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(); @@ -609,8 +656,13 @@ export function Plane({ spec }: { spec: PlaneSpec }) { 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; @@ -634,15 +686,47 @@ export function Plane({ spec }: { spec: PlaneSpec }) { * شیب را کد حساب می‌کند، چون مدل در بازنویسیِ 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]]; + 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]; diff --git a/src/diagram/types.ts b/src/diagram/types.ts index 6c7b08f..afe5a99 100644 --- a/src/diagram/types.ts +++ b/src/diagram/types.ts @@ -38,7 +38,7 @@ 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: 'line'; through?: [Ref, Ref]; /** «3x + 5y = 4»، همان‌طور که در مسئله نوشته شده */ equation?: string; /** ax + by + c = 0 */ eq?: [number, number, number] } & Style) | ({ type: 'ray'; from: Ref; through: Ref } & Style) | ({ type: 'vline'; x: number } & Style) | ({ type: 'hline'; y: number } & Style) @@ -47,6 +47,8 @@ export type PlaneItem = | ({ 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: 'regularPolygon'; n: number; r: number; center?: Ref; start?: number; names?: string[]; fill?: boolean } & Style) + | ({ type: 'exteriorAngle'; vertex: Ref; from: Ref; to: Ref } & 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)