From 2f8947ba940adad3ed1cba8367a575809c2c87d3 Mon Sep 17 00:00:00 2001 From: AliReza Date: Tue, 29 Sep 2026 06:11:43 +0330 Subject: [PATCH] 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 --- .gitignore | 1 + src/dev/diagramSamples.ts | 21 +++ src/diagram/index.tsx | 3 +- src/diagram/misc.tsx | 72 ++++++++- src/diagram/plane.tsx | 19 ++- src/diagram/shapes.ts | 300 ++++++++++++++++++++++++++++++++++++++ src/diagram/types.ts | 13 +- 7 files changed, 423 insertions(+), 6 deletions(-) create mode 100644 src/diagram/shapes.ts 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 ( + + {groups} + {spec.next && ( + + + ? + {`شکلِ ${toFa(stages.length + 1)}`} + + )} + + ); +} 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;