diagram kit: triangle and quad from problem data (law of sines/cosines), anchoring and side-by-side layout, shape patterns, translate points

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-29 06:11:43 +03:30
parent dffa940f8a
commit 2f8947ba94
7 changed files with 423 additions and 6 deletions

1
.gitignore vendored
View File

@@ -8,3 +8,4 @@ dist
dist.next
dist.prev
.deploy-backups
tsconfig.tsbuildinfo

View File

@@ -223,4 +223,25 @@ export const SAMPLES: { title: string; spec: DiagramSpec }[] = [
{ type: 'polygon', points: ['A', 'B', 'C', 'D', 'E'] }, { type: 'segment', from: 'B', to: 'X', dashed: true }, { type: 'angle', vertex: 'B', from: 'X', to: 'C', label: '72°' },
] } },
{ title: 'سوم — ضرب روی محورِ اعداد (تیک‌های زیاد)', spec: { kind: 'numberline', title: '۲ × ۱۱', min: 0, max: 25, points: [{ at: 11, label: '' }, { at: 22, label: '' }], jumps: [{ from: 0, to: 11, label: '۱۱' }, { from: 11, to: 22, label: '۱۱' }] } },
{ title: 'نهم — مثلث از روی داده: AB = 6، AC = 8، زاویه‌ی A قائمه', spec: { kind: 'plane', title: 'فیثاغورس', axes: false, points: {}, items: [
{ type: 'triangle', names: ['A', 'B', 'C'], sides: { AB: 6, AC: 8 }, angles: { A: 90 }, labels: { BC: 'x' } },
] } },
{ title: 'هشتم — مثلث با سه ضلع ۵، ۷، ۹ و ارتفاع از C', spec: { kind: 'plane', title: 'ارتفاعِ وارد بر AB', axes: false, points: {}, items: [
{ type: 'triangle', names: ['A', 'B', 'C'], sides: { AB: 9, BC: 7, CA: 5 }, altitude: 'C', labels: { h: 'h' } },
] } },
{ title: 'هفتم — مثلث با دو زاویه (۴۰° و ۷۵°)', spec: { kind: 'plane', title: 'زاویه‌ی سوم؟', axes: false, points: {}, items: [
{ type: 'triangle', names: ['A', 'B', 'C'], angles: { A: 40, B: 75 }, labels: { C: '?' } },
] } },
{ title: 'منفرجه — ارتفاع بیرونِ مثلث', spec: { kind: 'plane', title: 'زاویه‌ی B = ۱۲۰°', axes: false, points: {}, items: [
{ type: 'triangle', names: ['A', 'B', 'C'], sides: { AB: 4, BC: 3 }, angles: { B: 120 }, altitude: 'C' },
] } },
{ title: 'ششم — مستطیلِ باغچه ۸ × ۵', spec: { kind: 'plane', title: 'باغچه', axes: false, points: {}, items: [{ type: 'quad', shape: 'rectangle', names: ['A', 'B', 'C', 'D'], w: 8, h: 5 }] } },
{ title: 'مربع با ضلعِ ۴', spec: { kind: 'plane', title: 'مربع', axes: false, points: {}, items: [{ type: 'quad', shape: 'square', names: ['A', 'B', 'C', 'D'], side: 4 }] } },
{ title: 'ششم — متوازی‌الاضلاع: قاعده ۷، ارتفاع ۴', spec: { kind: 'plane', title: 'مساحت = قاعده × ارتفاع', axes: false, points: {}, items: [{ type: 'quad', shape: 'parallelogram', names: ['A', 'B', 'C', 'D'], w: 7, h: 4 }] } },
{ title: 'هفتم — لوزی با قطرهای ۸ و ۶', spec: { kind: 'plane', title: 'مساحت = نصفِ حاصل‌ضربِ قطرها', axes: false, points: {}, items: [{ type: 'quad', shape: 'rhombus', names: ['A', 'B', 'C', 'D'], d1: 8, d2: 6 }] } },
{ title: 'هشتم — ذوزنقه: قاعده‌های ۱۰ و ۶، ارتفاع ۴', spec: { kind: 'plane', title: 'ذوزنقه', axes: false, points: {}, items: [{ type: 'quad', shape: 'trapezoid', names: ['A', 'B', 'C', 'D'], w: 10, top: 6, h: 4 }] } },
{ title: 'ذوزنقه‌ی قائم‌الزاویه', spec: { kind: 'plane', title: 'ذوزنقه‌ی قائم', axes: false, points: {}, items: [{ type: 'quad', shape: 'trapezoid', names: ['A', 'B', 'C', 'D'], w: 8, top: 5, h: 4, right: true }] } },
{ title: 'هفتم — الگوی مربعی ۱، ۴، ۹ و شکلِ بعدی', spec: { kind: 'pattern', title: 'الگوی n²', layout: 'square', unit: 'square', stages: [1, 2, 3], next: true } },
{ title: 'هفتم — الگوی چوب‌کبریت', spec: { kind: 'pattern', title: 'چوب‌کبریت: 3n + 1', layout: 'row', unit: 'stick', stages: [1, 2, 3, 4], next: true } },
{ title: 'دهم — عددهای مثلثی با نقطه', spec: { kind: 'pattern', title: '۱، ۳، ۶، ۱۰', layout: 'triangle', unit: 'dot', stages: [1, 2, 3, 4] } },
];

View File

@@ -5,7 +5,7 @@ import { NumberLine } from './numberline';
import { Solid } from './solid';
import { Chart } from './charts';
import { Mapping, Tree, Venn } from './sets';
import { Clock, Fraction, Grid, Groups, Table } from './misc';
import { Clock, Fraction, Grid, Groups, Pattern, Table } from './misc';
import { PAPER } from './svg';
import { MathText } from '@/lib/MathText';
@@ -32,6 +32,7 @@ function Body({ spec }: { spec: DiagramSpec }): ReactNode {
case 'groups': return <Groups spec={spec} />;
case 'clock': return <Clock spec={spec} />;
case 'table': return <Table spec={spec} />;
case 'pattern': return <Pattern spec={spec} />;
default: return null;
}
}

View File

@@ -1,4 +1,4 @@
import type { ClockSpec, FractionSpec, GridSpec, GroupsSpec, TableSpec } from './types';
import type { ClockSpec, FractionSpec, GridSpec, GroupsSpec, PatternSpec, TableSpec } from './types';
import { COLORS, INK, bidi, color, mathText } from './svg';
import { toFa } from '@/lib/format';
@@ -217,3 +217,73 @@ export function Table({ spec }: { spec: TableSpec }) {
</div>
);
}
/* ─── الگوی شکلی: شکلِ ۱، ۲، ۳، … و «شکلِ بعدی؟» (فصلِ الگو و دنباله) ────────── */
/** خانه‌های هر مرحله در واحدِ شبکه؛ اولین مرحله راست است، مثلِ کتاب. */
function patternCells(layout: PatternSpec['layout'], n: number): [number, number][] {
const out: [number, number][] = [];
if (layout === 'square') for (let r = 0; r < n; r++) for (let c = 0; c < n; c++) out.push([c, r]);
else if (layout === 'rect') for (let r = 0; r < n; r++) for (let c = 0; c <= n; c++) out.push([c, r]);
else if (layout === 'triangle') for (let r = 0; r < n; r++) for (let c = 0; c <= r; c++) out.push([c + (n - 1 - r) / 2, r]);
else if (layout === 'stair') for (let c = 0; c < n; c++) for (let r = 0; r <= c; r++) out.push([c, n - 1 - r]);
else if (layout === 'L') { for (let r = 0; r < n; r++) out.push([0, r]); for (let c = 1; c < n; c++) out.push([c, n - 1]); }
else for (let c = 0; c < n; c++) out.push([c, 0]);
return out;
}
export function Pattern({ spec }: { spec: PatternSpec }) {
const stages = spec.stages;
const sticks = spec.unit === 'stick';
const sizes = stages.map((n) => {
const cells = sticks ? [] : patternCells(spec.layout, n);
const w = sticks ? n : Math.max(1, ...cells.map(([c]) => c + 1));
const h = sticks ? 1 : Math.max(1, ...cells.map(([, r]) => r + 1));
return { n, cells, w, h };
});
const gapU = 1.2;
const totalU = sizes.reduce((t, s) => t + s.w, 0) + gapU * (sizes.length - 1 + (spec.next ? 1 : 0)) + (spec.next ? 1.4 : 0);
const maxH = Math.max(...sizes.map((s) => s.h));
const u = Math.min(22, (W - 24) / totalU, 150 / maxH);
const H = maxH * u + 46;
const base = 10 + maxH * u;
const col = color(spec.color, COLORS.blue);
let x = W - 12;
const groups = sizes.map((s, i) => {
x -= s.w * u;
const x0 = x;
x -= gapU * u;
const count = sticks ? 3 * s.n + 1 : s.cells.length;
return (
<g key={i}>
{sticks
? Array.from({ length: s.n + 1 }, (_, k) => <line key={`v${k}`} x1={x0 + k * u} y1={base - u + 2} x2={x0 + k * u} y2={base - 2} stroke={COLORS.orange} strokeWidth={3} strokeLinecap="round" />)
.concat(Array.from({ length: s.n }, (_, k) => [
<line key={`t${k}`} x1={x0 + k * u + 2} y1={base - u} x2={x0 + (k + 1) * u - 2} y2={base - u} stroke={COLORS.orange} strokeWidth={3} strokeLinecap="round" />,
<line key={`b${k}`} x1={x0 + k * u + 2} y1={base} x2={x0 + (k + 1) * u - 2} y2={base} stroke={COLORS.orange} strokeWidth={3} strokeLinecap="round" />,
]).flat())
: s.cells.map(([c, r], k) => {
const cx = x0 + (s.w - 1 - c) * u, cy = base - (s.h - r) * u;
return spec.unit === 'dot'
? <circle key={k} cx={cx + u / 2} cy={cy + u / 2} r={u * 0.32} fill={col} />
: <rect key={k} x={cx + 1} y={cy + 1} width={u - 2} height={u - 2} rx={2} fill={`${col}33`} stroke={col} strokeWidth={1.5} />;
})}
<text x={x0 + (s.w * u) / 2} y={base + 16} fontSize={11} fontWeight={800} fill={INK} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>{`شکلِ ${toFa(i + 1)}`}</text>
{spec.counts !== false && <text x={x0 + (s.w * u) / 2} y={base + 30} fontSize={10.5} fill={INK} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>{`${toFa(count)} ${sticks ? 'چوب' : 'تا'}`}</text>}
</g>
);
});
const nx = x - 1.4 * u + gapU * u;
return (
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[340px]" role="img">
{groups}
{spec.next && (
<g>
<rect x={nx} y={base - 1.4 * u} width={1.4 * u} height={1.4 * u} rx={6} fill="#fff" stroke={COLORS.red} strokeWidth={2} strokeDasharray="4 3" />
<text x={nx + 0.7 * u} y={base - 0.45 * u} fontSize={u * 0.8} fontWeight={900} fill={COLORS.red} textAnchor="middle">?</text>
<text x={nx + 0.7 * u} y={base + 16} fontSize={11} fontWeight={800} fill={COLORS.red} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>{`شکلِ ${toFa(stages.length + 1)}`}</text>
</g>
)}
</svg>
);
}

View File

@@ -1,6 +1,7 @@
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';
/**
@@ -96,6 +97,10 @@ function resolvePoints(spec: PlaneSpec) {
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];
@@ -172,13 +177,23 @@ function RichLabel({ x, y, text, fill, size = 11, anchor = 'middle' }: { x: numb
* منتظم را خودش حدس زده بود و شکل منتظم نشد)
* 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) => i.type === 'regularPolygon' || i.type === 'exteriorAngle')) return spec;
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 === 'regularPolygon') {
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];
// پیش‌فرض: ضلعِ پایین افقی، مثلِ کتاب

300
src/diagram/shapes.ts Normal file
View File

@@ -0,0 +1,300 @@
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;
}

View File

@@ -6,6 +6,8 @@
* حساب می‌کند. هیچ مختصاتی که بشود حساب کرد از مدل گرفته نمی‌شود.
*/
import type { QuadItem, TriangleItem } from './shapes';
export type ColorName = 'blue' | 'red' | 'green' | 'orange' | 'purple' | 'teal' | 'pink' | 'gray' | 'black' | 'yellow';
export type Ref = string | [number, number];
@@ -18,7 +20,9 @@ export type PointDef =
| { foot: [Ref, Ref, Ref] }
| { intersect: [[Ref, Ref], [Ref, Ref]] }
| { along: [Ref, Ref, number] }
| { fx: [string, number] };
| { fx: [string, number] }
/** نقطه‌ی انتقال‌یافته با بردارِ [dx, dy] */
| { translate: [number, number]; from: Ref };
interface Style {
color?: ColorName;
@@ -49,6 +53,8 @@ export type PlaneItem =
| ({ 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)
| TriangleItem
| QuadItem
| ({ 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)
@@ -92,6 +98,9 @@ export interface FractionSpec {
}
export interface GridSpec { kind: 'grid'; title: string; rows: number; cols: number; shaded: number | [number, number][]; color?: ColorName }
export interface GroupsSpec { kind: 'groups'; title: string; groups: number; each: number; item?: 'circle' | 'square' | 'star'; label?: string }
/** الگوی شکلی: stages تعدادِ پایه‌ی هر مرحله (row: n در یک ردیف، square: n×n، rect: n×(n+1)،
* triangle: مثلثی، stair: پلکانی، L)؛ stick: n مربعِ چوب‌کبریتی در یک ردیف. */
export interface PatternSpec { kind: 'pattern'; title: string; layout: 'row' | 'square' | 'rect' | 'triangle' | 'stair' | 'L'; unit: 'square' | 'dot' | 'stick'; stages: number[]; next?: boolean; counts?: boolean; color?: ColorName }
export interface ClockSpec { kind: 'clock'; title: string; hour: number; minute: number }
export interface ChartSpec { kind: 'bar' | 'linechart' | 'pie'; title: string; labels: string[]; values: number[]; unit?: string }
export interface TableSpec { kind: 'table'; title: string; headers: string[]; rows: string[][] }
@@ -110,4 +119,4 @@ export interface SolidSpec {
export type DiagramSpec =
| PlaneSpec | NumberLineSpec | FractionSpec | GridSpec | GroupsSpec | ClockSpec
| ChartSpec | TableSpec | VennSpec | MappingSpec | TreeSpec | SolidSpec;
| ChartSpec | TableSpec | VennSpec | MappingSpec | TreeSpec | SolidSpec | PatternSpec;