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 ;
+}
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;