Files
MathApp_frontend/src/diagram/shapes.ts

301 lines
17 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 type { ColorName, PlaneItem, PointDef, Ref } from './types';
/**
* مثلث و چهارضلعی از روی داده‌های مسئله، نه از روی مختصاتِ حدسیِ مدل.
*
* مدل می‌گوید «مثلثی با AB = 6، AC = 8 و زاویه‌ی A = 90°» یا «ذوزنقه با قاعده‌های ۸ و ۵ و
* ارتفاعِ ۳»؛ این‌جا با قانونِ کسینوس‌ها و سینوس‌ها (مثلث) یا فرمولِ هر چهارضلعی رأس‌ها
* حساب می‌شوند و به نقطه + polygon + برچسب باز می‌شوند. سنجشِ ۲۰۲۶-۰۹-۲۸: مربعی که مدل
* مختصاتش را خودش نوشت کج درآمد؛ این ابزار آن خطا را از دستِ مدل بیرون می‌آورد.
* داده‌ی ناقص با پیش‌فرضِ ساده پر می‌شود (ضلعِ نامعلوم = میانگینِ ضلع‌های معلوم)، پس
* شکل همیشه کشیده می‌شود؛ داده‌ی متناقض (۳، ۴، ۱۰) مثلثی نمی‌سازد و item حذف می‌شود.
*/
type XY = [number, number];
const RAD = Math.PI / 180;
export interface TriangleItem {
type: 'triangle';
names: [string, string, string];
/** کلید: دو نامِ رأس («AB» یا «BA») */
sides?: Record<string, number>;
/** کلید: نامِ رأس، درجه */
angles?: Record<string, number>;
/** برچسبِ دلخواه برای ضلع («BC»: «x»)، رأس («A»: «?») یا ارتفاع («h») */
labels?: Record<string, string>;
/** رأسی که ارتفاع از آن بر ضلعِ روبه‌رو کشیده شود */
altitude?: string;
/** جای رأسِ اول */
at?: XY;
color?: ColorName;
fill?: boolean;
}
export interface QuadItem {
type: 'quad';
shape: 'rectangle' | 'square' | 'parallelogram' | 'rhombus' | 'trapezoid';
names: [string, string, string, string];
w?: number; h?: number; side?: number; angle?: number; top?: number; d1?: number; d2?: number;
right?: boolean;
at?: XY;
labels?: Record<string, string>;
color?: ColorName;
fill?: boolean;
}
interface Solved { a: number; b: number; c: number; A: number; B: number; C: number }
/** a روبه‌روی A و ...؛ زاویه‌ها به درجه. null یعنی داده‌ها مثلث نمی‌سازند. */
export function solveTriangle(sides: Partial<Record<'a' | 'b' | 'c', number>>, angles: Partial<Record<'A' | 'B' | 'C', number>>): Solved | null {
const s: Record<string, number | undefined> = { ...sides };
const g: Record<string, number | undefined> = { ...angles };
const opp = { a: 'A', b: 'B', c: 'C' } as const;
const known = (o: Record<string, number | undefined>, k: string) => o[k] !== undefined && Number.isFinite(o[k]!);
const step = () => {
const ks = ['A', 'B', 'C'].filter((k) => known(g, k));
if (ks.length === 2) {
const rest = 180 - ks.reduce((t, k) => t + g[k]!, 0);
if (rest <= 0) return false;
g[['A', 'B', 'C'].find((k) => !known(g, k))!] = rest;
}
const ss = (['a', 'b', 'c'] as const).filter((k) => known(s, k));
if (ss.length === 3 && ['A', 'B', 'C'].some((k) => !known(g, k))) {
const { a, b, c } = s as Record<string, number>;
if (a + b <= c || a + c <= b || b + c <= a) return false;
g.A = Math.acos((b * b + c * c - a * a) / (2 * b * c)) / RAD;
g.B = Math.acos((a * a + c * c - b * b) / (2 * a * c)) / RAD;
g.C = 180 - g.A - g.B;
}
// دو ضلع و زاویه‌ی بینشان
for (const [x, y, z] of [['b', 'c', 'a'], ['a', 'c', 'b'], ['a', 'b', 'c']] as const) {
if (known(s, x) && known(s, y) && !known(s, z) && known(g, opp[z])) {
s[z] = Math.sqrt(s[x]! ** 2 + s[y]! ** 2 - 2 * s[x]! * s[y]! * Math.cos(g[opp[z]]! * RAD));
}
}
// قانونِ سینوس‌ها
const pair = (['a', 'b', 'c'] as const).find((k) => known(s, k) && known(g, opp[k]));
if (pair) {
const k = s[pair]! / Math.sin(g[opp[pair]]! * RAD);
for (const side of ['a', 'b', 'c'] as const) {
const ang = opp[side];
if (!known(s, side) && known(g, ang)) s[side] = k * Math.sin(g[ang]! * RAD);
else if (known(s, side) && !known(g, ang)) {
const r = s[side]! / k;
if (r > 1 + 1e-9) return false;
g[ang] = Math.asin(Math.min(1, r)) / RAD;
}
}
}
return true;
};
const done = () => ['a', 'b', 'c'].every((k) => known(s, k)) && ['A', 'B', 'C'].every((k) => known(g, k));
const count = () => ['a', 'b', 'c'].filter((k) => known(s, k)).length + ['A', 'B', 'C'].filter((k) => known(g, k)).length;
for (let round = 0; round < 12 && !done(); round++) {
const before = count();
if (!step()) return null;
if (done()) break;
if (count() > before) continue;
// گیر کرد: داده‌ی ناقص. ضلعِ نامعلومِ اول = میانگینِ ضلع‌های معلوم (متساوی‌الساقین/متساوی‌الاضلاع)
const ss = (['a', 'b', 'c'] as const).filter((k) => known(s, k));
if (!ss.length) { s.c = 4; continue; }
const missing = (['a', 'b', 'c'] as const).find((k) => !known(s, k));
if (missing) s[missing] = ss.reduce((t, k) => t + s[k]!, 0) / ss.length;
}
if (!done()) return null;
const out = { a: s.a!, b: s.b!, c: s.c!, A: g.A!, B: g.B!, C: g.C! };
if (Object.values(out).some((v) => !Number.isFinite(v) || v <= 0) || Math.abs(out.A + out.B + out.C - 180) > 0.5) return null;
return out;
}
const num = (v: number) => String(Math.round(v * 100) / 100);
/** رأس‌های شکل‌های مرکبی که تا این‌جا جا گرفته‌اند (برای چیدنِ چند شکل کنارِ هم). */
export interface Layout { placed: XY[] }
/**
* جای شکل: رأس‌ها در دستگاهِ محلی (رأسِ اول در مبدأ، ضلعِ اول روی محورِ x) ساخته شده‌اند.
* دو رأسِ اول از قبل مختصات دارند → شکل روی همان ضلع ساخته می‌شود (ذوزنقه روی ضلعِ مستطیل)
* فقط رأسِ اول → همان‌جا؛ "at" → همان‌جا
* هیچ‌کدام، ولی شکلِ دیگری قبلاً کشیده شده → کنارِ آن، نه روی آن (دو مربعِ کنارِ هم)
*/
function placement(names: string[], local: XY[], points: Record<string, PointDef>, layout: Layout, at?: XY): (p: XY) => XY {
const plain = (n: string) => (Array.isArray(points[n]) ? (points[n] as XY) : null);
const p0 = plain(names[0]), p1 = plain(names[1]);
let T: (p: XY) => XY = (p) => p;
if (p0 && p1 && Math.hypot(p1[0] - p0[0], p1[1] - p0[1]) > 1e-9) {
const len = Math.hypot(p1[0] - p0[0], p1[1] - p0[1]);
const u: XY = [(p1[0] - p0[0]) / len, (p1[1] - p0[1]) / len];
T = (p) => [p0[0] + p[0] * u[0] - p[1] * u[1], p0[1] + p[0] * u[1] + p[1] * u[0]];
} else if (p0) T = (p) => [p0[0] + p[0], p0[1] + p[1]];
else if (at) T = (p) => [at[0] + p[0], at[1] + p[1]];
else if (layout.placed.length) {
const right = Math.max(...layout.placed.map((q) => q[0]));
const bottom = Math.min(...layout.placed.map((q) => q[1]));
const minX = Math.min(...local.map((q) => q[0])), minY = Math.min(...local.map((q) => q[1]));
const gap = Math.max(1, 0.2 * (Math.max(...local.map((q) => q[0])) - minX));
T = (p) => [p[0] - minX + right + gap, p[1] - minY + bottom];
}
layout.placed.push(...local.map(T));
return T;
}
function sideKey(key: string, names: string[]): [number, number] | null {
for (let i = 0; i < names.length; i++) for (let j = 0; j < names.length; j++) {
if (i !== j && key === names[i] + names[j]) return [i, j];
}
return null;
}
/** item‌های ساده (نقطه، polygon، پاره‌خطِ برچسب‌دار، زاویه) به‌جای triangle. */
export function expandTriangle(item: TriangleItem, idx: number, points: Record<string, PointDef>, taken: (n: string) => boolean, layout: Layout): PlaneItem[] | null {
const [P, Q, R] = item.names;
const sides: Partial<Record<'a' | 'b' | 'c', number>> = {};
const oppOf = (i: number, j: number) => (['a', 'b', 'c'] as const)[3 - i - j];
Object.entries(item.sides ?? {}).forEach(([k, v]) => { const p = sideKey(k, item.names); if (p) sides[oppOf(p[0], p[1])] = v; });
const angles: Partial<Record<'A' | 'B' | 'C', number>> = {};
Object.entries(item.angles ?? {}).forEach(([k, v]) => { const i = item.names.indexOf(k); if (i >= 0) angles[(['A', 'B', 'C'] as const)[i]] = v; });
// مثلثی روی نقطه‌هایی که مدل خودش تعریف کرده (مختصات، polar، تبدیل) و بی‌داده‌ی ضلع و زاویه:
// همان نقطه‌ها، دست‌نخورده (سنجشِ ۲۰۲۶-۰۹-۲۹: جابه‌جا کردنشان تصویرِ تقارن را خراب کرد)
const hasData = Object.keys(sides).length + Object.keys(angles).length > 0;
if (!(hasData || !item.names.every((n) => n in points))) {
layout.placed.push(...item.names.map((n) => points[n]).filter((v): v is XY => Array.isArray(v)));
} else {
const t = solveTriangle(sides, angles);
if (!t) return null;
const local: XY[] = [[0, 0], [t.c, 0], [t.b * Math.cos(t.A * RAD), t.b * Math.sin(t.A * RAD)]];
const T = placement(item.names, local, points, layout, item.at);
item.names.forEach((n, i) => { points[n] = T(local[i]); });
}
const style = { color: item.color };
const out: PlaneItem[] = [{ type: 'polygon', points: [P, Q, R], fill: item.fill ?? true, ...style }];
const labels = item.labels ?? {};
const sideLabel = new Map<string, string>();
Object.entries(item.sides ?? {}).forEach(([k, v]) => { const p = sideKey(k, item.names); if (p) sideLabel.set([p[0], p[1]].sort().join(), num(v)); });
Object.entries(labels).forEach(([k, v]) => { const p = sideKey(k, item.names); if (p) sideLabel.set([p[0], p[1]].sort().join(), v); });
// همه‌ی ضلع‌ها پاره‌خط‌اند (نه فقط برچسب‌دارها) تا ضخامتِ ضلع‌ها یکی باشد
[[0, 1], [1, 2], [0, 2]].forEach(([i, j]) => {
out.push({ type: 'segment', from: item.names[i], to: item.names[j], label: sideLabel.get(`${i},${j}`), ...style });
});
item.names.forEach((n, i) => {
const given = item.angles?.[n];
const label = labels[n] ?? (given !== undefined ? `${num(given)}°` : undefined);
if (label === undefined) return;
const [from, to] = [item.names[(i + 1) % 3], item.names[(i + 2) % 3]];
if (given !== undefined && Math.abs(given - 90) < 1e-6 && labels[n] === undefined) out.push({ type: 'rightAngle', vertex: n, from, to });
else out.push({ type: 'angle', vertex: n, from, to, label, color: 'red' });
});
const alt = item.altitude;
if (alt && item.names.includes(alt)) {
const [u, v] = item.names.filter((n) => n !== alt);
const foot = taken('H') ? `_h${idx}` : 'H';
points[foot] = { foot: [alt, u, v] };
// امتدادِ ضلع فقط وقتی پای ارتفاع بیرونِ مثلث می‌افتد (مثلثِ منفرجه)
const [pu, pv, pa] = [u, v, alt].map((n) => (Array.isArray(points[n]) ? (points[n] as XY) : ([0, 0] as XY)));
const d: XY = [pv[0] - pu[0], pv[1] - pu[1]];
const tt = ((pa[0] - pu[0]) * d[0] + (pa[1] - pu[1]) * d[1]) / (d[0] * d[0] + d[1] * d[1] || 1);
if (tt < 0) out.push({ type: 'segment', from: foot, to: u, dashed: true, color: 'gray' });
if (tt > 1) out.push({ type: 'segment', from: v, to: foot, dashed: true, color: 'gray' });
out.push({ type: 'segment', from: alt, to: foot, dashed: true, color: 'green', label: labels.h });
out.push({ type: 'rightAngle', vertex: foot, from: alt, to: v });
}
return out;
}
export function expandQuad(item: QuadItem, idx: number, points: Record<string, PointDef>, layout: Layout): PlaneItem[] {
const [A, B, C, D] = item.names;
const labels = item.labels ?? {};
const style = { color: item.color };
const lab = (key: string, value?: number) => labels[key] ?? (value !== undefined ? num(value) : undefined);
const out: PlaneItem[] = [];
const seg = (from: Ref, to: Ref, label?: string, extra: Partial<Extract<PlaneItem, { type: 'segment' }>> = {}) => {
out.push({ type: 'segment', from, to, label, ...style, ...extra });
};
// ضلع‌هایی که برچسب یا علامت نگرفتند، بعداً با همان ضخامت کشیده می‌شوند
const edges = () => {
const drawn = new Set(out.filter((i) => i.type === 'segment' && !i.dashed).map((i) => { const s = i as { from: Ref; to: Ref }; return [s.from, s.to].sort().join(); }));
[[A, B], [B, C], [C, D], [D, A]].forEach(([p, q]) => { if (!drawn.has([p, q].sort().join())) seg(p, q); });
};
// بی‌هیچ اندازه و روی چهار نقطه‌ای که مدل خودش تعریف کرده: همان نقطه‌ها
const keep = ['w', 'h', 'side', 'top', 'd1', 'd2', 'angle'].every((k) => item[k as keyof QuadItem] === undefined)
&& item.names.every((n) => n in points);
let T: (p: XY) => XY = (p) => p;
const put = (xy: XY[]) => {
if (keep) { layout.placed.push(...item.names.map((n) => points[n]).filter((v): v is XY => Array.isArray(v))); return; }
T = placement(item.names, xy, points, layout, item.at);
[A, B, C, D].forEach((n, i) => { points[n] = T(xy[i]); });
};
const height = (label?: string) => {
const foot = `_q${idx}`;
points[foot] = { foot: [D, A, B] };
out.push({ type: 'segment', from: D, to: foot, dashed: true, color: 'green', label });
out.push({ type: 'rightAngle', vertex: foot, from: D, to: B });
};
const θ = (item.angle ?? 60) * RAD;
switch (item.shape) {
case 'rectangle': {
const w = item.w ?? 6, h = item.h ?? 4;
put([[0, 0], [w, 0], [w, h], [0, h]]);
out.push({ type: 'polygon', points: [A, B, C, D], fill: item.fill ?? true, ...style }, { type: 'rightAngle', vertex: A, from: B, to: D });
seg(A, B, lab(A + B, item.w)); seg(B, C, lab(B + C, item.h));
break;
}
case 'square': {
const s = item.side ?? item.w ?? 4;
put([[0, 0], [s, 0], [s, s], [0, s]]);
out.push({ type: 'polygon', points: [A, B, C, D], fill: item.fill ?? true, ...style }, { type: 'rightAngle', vertex: A, from: B, to: D });
seg(A, B, lab(A + B, item.side ?? item.w), { ticks: 1 }); seg(B, C, lab(B + C), { ticks: 1 });
seg(C, D, lab(C + D), { ticks: 1 }); seg(D, A, lab(D + A), { ticks: 1 });
break;
}
case 'parallelogram': {
const w = item.w ?? 6;
const s = item.side ?? (item.h ? item.h / Math.sin(θ) : 3.5);
const d: XY = [s * Math.cos(θ), s * Math.sin(θ)];
put([[0, 0], [w, 0], [w + d[0], d[1]], d]);
out.push({ type: 'polygon', points: [A, B, C, D], fill: item.fill ?? true, ...style });
seg(A, B, lab(A + B, item.w)); seg(A, D, lab(A + D, item.side));
if (item.h !== undefined || labels.h) height(lab('h', item.h));
if (item.angle !== undefined) out.push({ type: 'angle', vertex: A, from: B, to: D, label: `${num(item.angle)}°`, color: 'red' });
break;
}
case 'rhombus': {
if (item.d1 !== undefined || item.d2 !== undefined) {
const d1 = item.d1 ?? 6, d2 = item.d2 ?? 4, o = `_o${idx}`;
put([[-d1 / 2, 0], [0, -d2 / 2], [d1 / 2, 0], [0, d2 / 2]]);
points[o] = T([0, 0]);
out.push({ type: 'polygon', points: [A, B, C, D], fill: item.fill ?? true, ...style });
// برچسبِ قطرها کنارِ هم نمی‌نشیند (هر دو وسطشان در O است): روی یک‌چهارمِ هر قطر
seg(A, C, undefined, { dashed: true }); seg(B, D, undefined, { dashed: true });
const l1 = lab('d1', item.d1), l2 = lab('d2', item.d2);
if (l1) out.push({ type: 'text', at: T([d1 / 4, d2 * 0.07]), text: l1, color: 'blue' });
if (l2) out.push({ type: 'text', at: T([d1 * 0.07, d2 / 4]), text: l2, color: 'blue' });
out.push({ type: 'rightAngle', vertex: o, from: C, to: D });
[[A, B], [B, C], [C, D], [D, A]].forEach(([p, q]) => seg(p, q, lab(p + q), { ticks: 1 }));
} else {
const s = item.side ?? item.w ?? 4;
put([[0, 0], [s, 0], [s + s * Math.cos(θ), s * Math.sin(θ)], [s * Math.cos(θ), s * Math.sin(θ)]]);
out.push({ type: 'polygon', points: [A, B, C, D], fill: item.fill ?? true, ...style });
seg(A, B, lab(A + B, item.side ?? item.w), { ticks: 1 });
[[B, C], [C, D], [D, A]].forEach(([p, q]) => seg(p, q, lab(p + q), { ticks: 1 }));
if (item.h !== undefined || labels.h) height(lab('h', item.h));
if (item.angle !== undefined) out.push({ type: 'angle', vertex: A, from: B, to: D, label: `${num(item.angle)}°`, color: 'red' });
}
break;
}
case 'trapezoid': {
const w = item.w ?? 6, t = item.top ?? w * 0.6, h = item.h ?? 3;
const x0 = item.right ? 0 : (w - t) / 2;
put([[0, 0], [w, 0], [x0 + t, h], [x0, h]]);
out.push({ type: 'polygon', points: [A, B, C, D], fill: item.fill ?? true, ...style });
seg(A, B, lab(A + B, item.w)); seg(D, C, lab(D + C, item.top));
if (item.right) {
out.push({ type: 'rightAngle', vertex: A, from: B, to: D });
seg(A, D, lab(A + D, item.h) ?? lab('h'));
} else if (item.h !== undefined || labels.h) height(lab('h', item.h));
break;
}
}
edges();
return out;
}