diff --git a/.gitignore b/.gitignore
index 1906164..c17ebec 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,4 @@ dist
dist.next
dist.prev
.deploy-backups
+tsconfig.tsbuildinfo
diff --git a/src/dev/diagramSamples.ts b/src/dev/diagramSamples.ts
index d4dd88e..b527585 100644
--- a/src/dev/diagramSamples.ts
+++ b/src/dev/diagramSamples.ts
@@ -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] } },
];
diff --git a/src/diagram/index.tsx b/src/diagram/index.tsx
index 25a5ec2..67b6301 100644
--- a/src/diagram/index.tsx
+++ b/src/diagram/index.tsx
@@ -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 ;
case 'clock': return ;
case 'table': return
;
+ case 'pattern': return ;
default: return null;
}
}
diff --git a/src/diagram/misc.tsx b/src/diagram/misc.tsx
index cf4b050..9827d8e 100644
--- a/src/diagram/misc.tsx
+++ b/src/diagram/misc.tsx
@@ -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 }) {
);
}
+
+/* ─── الگوی شکلی: شکلِ ۱، ۲، ۳، … و «شکلِ بعدی؟» (فصلِ الگو و دنباله) ────────── */
+
+/** خانههای هر مرحله در واحدِ شبکه؛ اولین مرحله راست است، مثلِ کتاب. */
+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 (
+
+ {sticks
+ ? Array.from({ length: s.n + 1 }, (_, k) => )
+ .concat(Array.from({ length: s.n }, (_, k) => [
+ ,
+ ,
+ ]).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'
+ ?
+ : ;
+ })}
+ {`شکلِ ${toFa(i + 1)}`}
+ {spec.counts !== false && {`${toFa(count)} ${sticks ? 'چوب' : 'تا'}`}}
+
+ );
+ });
+ const nx = x - 1.4 * u + gapU * u;
+ return (
+
+ );
+}
diff --git a/src/diagram/plane.tsx b/src/diagram/plane.tsx
index 81f1f8e..981802e 100644
--- a/src/diagram/plane.tsx
+++ b/src/diagram/plane.tsx
@@ -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 = { ...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];
// پیشفرض: ضلعِ پایین افقی، مثلِ کتاب
diff --git a/src/diagram/shapes.ts b/src/diagram/shapes.ts
new file mode 100644
index 0000000..085bbcb
--- /dev/null
+++ b/src/diagram/shapes.ts
@@ -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;
+ /** کلید: نامِ رأس، درجه */
+ angles?: Record;
+ /** برچسبِ دلخواه برای ضلع («BC»: «x»)، رأس («A»: «?») یا ارتفاع («h») */
+ labels?: Record;
+ /** رأسی که ارتفاع از آن بر ضلعِ روبهرو کشیده شود */
+ 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;
+ 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>, angles: Partial>): Solved | null {
+ const s: Record = { ...sides };
+ const g: Record = { ...angles };
+ const opp = { a: 'A', b: 'B', c: 'C' } as const;
+ const known = (o: Record, 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;
+ 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, 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, taken: (n: string) => boolean, layout: Layout): PlaneItem[] | null {
+ const [P, Q, R] = item.names;
+ const sides: Partial> = {};
+ 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> = {};
+ 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();
+ 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, 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> = {}) => {
+ 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;
+}
diff --git a/src/diagram/types.ts b/src/diagram/types.ts
index afe5a99..3af54c5 100644
--- a/src/diagram/types.ts
+++ b/src/diagram/types.ts
@@ -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;