From 12c3721d9222c2eef1489db064dd144e3e814636 Mon Sep 17 00:00:00 2001 From: AliReza Date: Thu, 15 Oct 2026 01:04:08 +0330 Subject: [PATCH] diagram: kind=space (fixed oblique 3D view) Co-Authored-By: Claude Sonnet 5.5 --- src/diagram/index.tsx | 2 + src/diagram/space.tsx | 125 ++++++++++++++++++++++++++++++++++++++++++ src/diagram/types.ts | 10 +++- 3 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 src/diagram/space.tsx diff --git a/src/diagram/index.tsx b/src/diagram/index.tsx index 67b6301..f3d728a 100644 --- a/src/diagram/index.tsx +++ b/src/diagram/index.tsx @@ -3,6 +3,7 @@ import type { DiagramSpec } from './types'; import { Plane } from './plane'; import { NumberLine } from './numberline'; import { Solid } from './solid'; +import { Space } from './space'; import { Chart } from './charts'; import { Mapping, Tree, Venn } from './sets'; import { Clock, Fraction, Grid, Groups, Pattern, Table } from './misc'; @@ -23,6 +24,7 @@ function Body({ spec }: { spec: DiagramSpec }): ReactNode { case 'plane': return ; case 'numberline': return ; case 'solid': return ; + case 'space': return ; case 'bar': case 'linechart': case 'pie': return ; case 'venn': return ; case 'mapping': return ; diff --git a/src/diagram/space.tsx b/src/diagram/space.tsx new file mode 100644 index 0000000..519c2be --- /dev/null +++ b/src/diagram/space.tsx @@ -0,0 +1,125 @@ +import type { ReactNode } from 'react'; +import type { SpaceSpec } from './types'; +import { COLORS, INK, Label, SOFT, color, mathText } from './svg'; + +/** + * فضای سه‌بعدی با تصویرِ مایلِ ثابت: x رو به بیننده (پایین‌چپ)، y راست، z بالا. چرخش و تعامل ندارد. + * هر نقطه (x, y, z) به صفحه می‌رود با sx = y − 0.46·x ، sy = z − 0.36·x و کلِ شکل برای جا شدن در + * کادر مقیاس می‌شود. جهتِ x کوتاه‌شده است (نسبتِ ۰٫۵۸) تا طولِ ظاهری بزرگ‌تر از واقعی دیده نشود. + */ +type XY = [number, number]; +type P3 = [number, number, number]; +const W = 320; +const H = 270; +const PAD = 34; +const proj = ([x, y, z]: P3): XY => [y - 0.46 * x, z - 0.36 * x]; + +export function Space({ spec }: { spec: SpaceSpec }) { + const pts: Record = { O: [0, 0, 0], ...spec.points }; + const items = spec.items ?? []; + const get = (n: string): P3 | undefined => pts[n]; + + // کرانِ شکل: همه‌ی نقطه‌ها، جعبه‌ها و محورها (محور تا کمی بیشتر از بزرگ‌ترین مختصات) + const all: P3[] = Object.values(pts); + for (const it of items) if (it.type === 'box') all.push(it.to); + const ext = (k: 0 | 1 | 2) => Math.max(1, ...all.map((p) => Math.abs(p[k]))); + const ax: P3 = [ext(0) * 1.15, ext(1) * 1.15, ext(2) * 1.15]; + const corners: P3[] = [...all]; + if (spec.axes !== false) corners.push([ax[0], 0, 0], [0, ax[1], 0], [0, 0, ax[2]]); + const xs = corners.map((c) => proj(c)[0]), ys = corners.map((c) => proj(c)[1]); + const [x0, x1, y0, y1] = [Math.min(...xs), Math.max(...xs), Math.min(...ys), Math.max(...ys)]; + const s = Math.min((W - 2 * PAD) / Math.max(x1 - x0, 1e-6), (H - 2 * PAD) / Math.max(y1 - y0, 1e-6)); + const ox = (W - (x1 - x0) * s) / 2 - x0 * s; + const oy = (H - (y1 - y0) * s) / 2 + y1 * s; + const T = (p: P3): XY => { + const [u, v] = proj(p); + return [ox + u * s, oy - v * s]; + }; + + const els: ReactNode[] = []; + let key = 0; + const k = () => key++; + const line = (a: P3, b: P3, col: string, w = 1.8, dash?: string, arrow = false) => { + const A = T(a), B = T(b); + els.push(); + if (arrow) { + const ang = Math.atan2(B[1] - A[1], B[0] - A[0]); + const h = 9, a1 = ang + Math.PI - 0.38, a2 = ang + Math.PI + 0.38; + els.push(); + } + }; + const text = (p: XY, t: string | undefined, col = INK, dx = 8, dy = -7) => { + if (t) els.push(); + }; + + // صفحه‌های مختصات به‌صورت شبکه‌ی کم‌رنگ پشت همه چیز + if (spec.axes !== false) { + const mk = (a: P3, b: P3, c: P3, d: P3) => { + const q = [a, b, c, d].map(T); + els.push( p.join(',')).join(' L')} Z`} fill={`${COLORS.blue}0c`} stroke="none" />); + }; + mk([0, 0, 0], [0, ax[1], 0], [ax[0], ax[1], 0], [ax[0], 0, 0]); + line([0, 0, 0], [ax[0], 0, 0], SOFT, 1.6, undefined, true); + line([0, 0, 0], [0, ax[1], 0], SOFT, 1.6, undefined, true); + line([0, 0, 0], [0, 0, ax[2]], SOFT, 1.6, undefined, true); + text(T([ax[0], 0, 0]), 'x', SOFT, -10, 12); + text(T([0, ax[1], 0]), 'y', SOFT, 8, 4); + text(T([0, 0, ax[2]]), 'z', SOFT, 8, 6); + } + + // چندضلعی‌ها اول (نیمه‌شفاف)، بعد جعبه‌ها، پاره‌خط‌ها، نقطه‌ها + for (const it of items) { + if (it.type !== 'polygon') continue; + const ps = it.points.map(get).filter((p): p is P3 => !!p); + if (ps.length < 3) continue; + const q = ps.map(T); + const c = color(it.color, COLORS.blue); + els.push( p.join(',')).join(' L')} Z`} fill={`${c}26`} stroke={c} strokeWidth={1.8} strokeLinejoin="round" />); + text([q.reduce((a, p) => a + p[0], 0) / q.length, q.reduce((a, p) => a + p[1], 0) / q.length], it.label, c, 0, 4); + } + for (const it of items) { + if (it.type !== 'box') continue; + const [a, b, c] = it.to; + const col = color(it.color, COLORS.teal); + const v = (i: number, j: number, l: number): P3 => [i * a, j * b, l * c]; + const hidden: [P3, P3][] = [[v(0, 0, 0), v(1, 0, 0)], [v(0, 0, 0), v(0, 1, 0)], [v(0, 0, 0), v(0, 0, 1)]]; + const shown: [P3, P3][] = [ + [v(1, 0, 0), v(1, 1, 0)], [v(0, 1, 0), v(1, 1, 0)], + [v(1, 0, 0), v(1, 0, 1)], [v(0, 1, 0), v(0, 1, 1)], [v(1, 1, 0), v(1, 1, 1)], + [v(0, 0, 1), v(1, 0, 1)], [v(0, 0, 1), v(0, 1, 1)], [v(1, 0, 1), v(1, 1, 1)], [v(0, 1, 1), v(1, 1, 1)], + ]; + hidden.forEach(([p, q]) => line(p, q, col, 1.3, '4 3')); + shown.forEach(([p, q]) => line(p, q, col, 1.8)); + } + for (const it of items) { + if (it.type === 'segment' || it.type === 'vector') { + const a = get(it.from), b = get(it.to); + if (!a || !b) continue; + const col = color(it.color, it.type === 'vector' ? COLORS.blue : INK); + line(a, b, col, 2.2, it.dashed ? '5 4' : undefined, it.type === 'vector'); + const A = T(a), B = T(b); + text([(A[0] + B[0]) / 2, (A[1] + B[1]) / 2], it.label, col, 8, -6); + } else if (it.type === 'drop') { + const p = get(it.point); + if (!p) continue; + const col = color(it.color, SOFT); + line(p, [p[0], p[1], 0], col, 1.2, '3 3'); + line([p[0], p[1], 0], [p[0], 0, 0], col, 1.2, '3 3'); + line([p[0], p[1], 0], [0, p[1], 0], col, 1.2, '3 3'); + } + } + for (const it of items) { + if (it.type !== 'point') continue; + const p = get(it.point); + if (!p) continue; + const q = T(p), col = color(it.color, INK); + els.push(); + text(q, it.label ?? it.point, col, 8, -6); + } + // مبدأ + const o = T([0, 0, 0]); + els.push(); + if (!items.some((i) => i.type === 'point' && i.point === 'O')) text(o, 'O', SOFT, -9, 13); + + return {els}; +} diff --git a/src/diagram/types.ts b/src/diagram/types.ts index 3af54c5..0f671ba 100644 --- a/src/diagram/types.ts +++ b/src/diagram/types.ts @@ -117,6 +117,14 @@ export interface SolidSpec { labels?: { a?: string; b?: string; c?: string; r?: string; h?: string }; } +export type SpaceItem = + | { type: 'point' | 'drop'; point: string; label?: string; color?: ColorName } + | { type: 'vector' | 'segment'; from: string; to: string; label?: string; dashed?: boolean; color?: ColorName } + | { type: 'polygon'; points: string[]; label?: string; color?: ColorName } + | { type: 'box'; to: [number, number, number]; label?: string; color?: ColorName }; +/** فضای سه‌بعدی با تصویرِ مایلِ ثابت (x رو به بیننده، y راست، z بالا). */ +export interface SpaceSpec { kind: 'space'; title: string; axes?: boolean; points: Record; items: SpaceItem[] } + export type DiagramSpec = | PlaneSpec | NumberLineSpec | FractionSpec | GridSpec | GroupsSpec | ClockSpec - | ChartSpec | TableSpec | VennSpec | MappingSpec | TreeSpec | SolidSpec | PatternSpec; + | ChartSpec | TableSpec | VennSpec | MappingSpec | TreeSpec | SolidSpec | PatternSpec | SpaceSpec;