Files
MathApp_frontend/src/diagram/space.tsx
2026-10-15 01:04:08 +03:30

126 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, P3> = { 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(<line key={k()} x1={A[0]} y1={A[1]} x2={B[0]} y2={B[1]} stroke={col} strokeWidth={w} strokeDasharray={dash} strokeLinecap="round" />);
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(<path key={k()} d={`M${B[0]},${B[1]} L${B[0] + h * Math.cos(a1)},${B[1] + h * Math.sin(a1)} L${B[0] + h * Math.cos(a2)},${B[1] + h * Math.sin(a2)} Z`} fill={col} />);
}
};
const text = (p: XY, t: string | undefined, col = INK, dx = 8, dy = -7) => {
if (t) els.push(<Label key={k()} x={p[0] + dx} y={p[1] + dy} color={col} size={12}>{mathText(t)}</Label>);
};
// صفحه‌های مختصات به‌صورت شبکه‌ی کم‌رنگ پشت همه چیز
if (spec.axes !== false) {
const mk = (a: P3, b: P3, c: P3, d: P3) => {
const q = [a, b, c, d].map(T);
els.push(<path key={k()} d={`M${q.map((p) => 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(<path key={k()} d={`M${q.map((p) => 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(<circle key={k()} cx={q[0]} cy={q[1]} r={3.6} fill={col} />);
text(q, it.label ?? it.point, col, 8, -6);
}
// مبدأ
const o = T([0, 0, 0]);
els.push(<circle key={k()} cx={o[0]} cy={o[1]} r={2.6} fill={SOFT} />);
if (!items.some((i) => i.type === 'point' && i.point === 'O')) text(o, 'O', SOFT, -9, 13);
return <svg viewBox={`0 0 ${W} ${H}`} width="100%" role="img" aria-label="شکلِ سه‌بعدی">{els}</svg>;
}