奇异值分解(SVD):从几何直觉理解矩阵的本质
一篇写给"想真正看懂矩阵在干什么"的人的可视化笔记
引言:矩阵到底在做什么?
当我们写下一个2x2矩阵时,它仅仅是四个数字的排列吗?
不是。矩阵是一个变换------它把空间中的每一个向量送到另一个位置。但仅仅说"它是一个线性变换"过于抽象,我们需要一个更直观的画面:
任何矩阵作用在单位圆上,都会把它变成一个椭圆。
这个看似简单的事实,背后藏着线性代数中最优雅的定理之一:奇异值分解(Singular Value Decomposition, SVD)。
这篇博客的目标是:通过一个交互式可视化工具,让你亲眼看见奇异值、奇异向量、特征值之间的差异,并建立一套"看到矩阵数字就能想象出几何形状"的直觉。
我们要回答的核心问题
读完这篇文章并把玩可视化工具后,你应该能够:
- 看到一个 2×2 矩阵的四个数字,立刻在脑中画出它的椭圆形状
- 区分"特征向量"和"奇异向量" ------ 它们经常被混淆,但其实是两个完全不同的概念
- 理解 SVD 的三步分解:旋转 → 拉伸 → 旋转
- 从 2D 推广到 3D 乃至更高维,理解为什么 n 维空间有 n(n−1)/2 个旋转平面
一、奇异值是什么?
定义
对于任意一个 m×n 的矩阵 A,奇异值分解告诉我们它一定可以写成:
A=UΣVT A = U \Sigma V^T A=UΣVT
其中:
- V :n×n 正交矩阵,列向量是右奇异向量 (vi\mathbf{v}_ivi
- Σ:对角矩阵,对角线上是奇异值
- U :m×m 正交矩阵,列向量是左奇异向量 (ui\mathbf{u}_iui)
几何意义(这才是重点)
把 A 作用在单位圆上,会得到一个椭圆。那么:
- 奇异值 (σi\sigma_iσi) = 椭圆半轴的长度
- 右奇异向量 (vi\mathbf{v}_ivi) = 单位圆上"将被拉成主轴"的那些方向
- 左奇异向量 (ui\mathbf{u}_iui) = 椭圆主轴的方向 (即 (AviA\mathbf{v}_iAvi) 归一化后)
换句话说:SVD 找到了输入空间中的一组正交方向,使得它们经过 A 变换后依然正交,并且各自被拉伸 (σi\sigma_iσi) 倍。
二、SVD 的三步几何分解
矩阵 A 的作用可以被理解为三个连续步骤:
单位圆 ──[V^T 旋转]──> 旋转后的圆 ──[Σ 拉伸]──> 轴对齐椭圆 ──[U 旋转]──> 最终椭圆
- V^T:把右奇异向量旋转到标准坐标轴上
- Σ :沿坐标轴方向各自拉伸 (σ1,σ2,...\sigma_1, \sigma_2, \ldotsσ1,σ2,...) 倍
- U:把椭圆旋转到最终的姿态
任何线性变换,本质上都是"转一下、拉一下、再转一下"。 这就是 SVD 的几何灵魂。
三、奇异向量 ≠ 特征向量
这是最容易混淆、也是可视化工具最有助益的地方。
| 概念 | 定义 | 几何含义 | 是否正交 |
|---|---|---|---|
| 特征向量 | (Ax=λxA\mathbf{x} = \lambda \mathbf{x}Ax=λx) | 经过变换后方向不变的向量 | 一般不正交 |
| 右奇异向量 | (ATAA^T AATA) 的特征向量 | 经过变换后变成椭圆主轴的输入方向 | 始终正交 |
只有当 A 是对称矩阵时,特征向量和奇异向量才重合。
对于 (A=[2135]A = \begin{bmatrix} 2 & 1 \\ 3 & 5 \end{bmatrix}A=[2315]):
- 特征向量夹角不是 90°(紫色箭头)
- 右奇异向量始终垂直(玫红色虚线)
- 经过 A 变换后,奇异向量变成椭圆的长短轴(玫红色实线),仍然垂直
四、看数字读矩阵:建立直觉
对于 2×2 矩阵 (A=[abcd]A = \begin{bmatrix} a & b \\ c & d \end{bmatrix}A=[acbd]),我们可以训练这样一套读法:
1. 对角线决定"基础体型"
- a, d 控制 x、y 方向的基础拉伸
- d > a → 瘦高椭圆;a > d → 矮胖椭圆
2. 反对角线决定"扭转"
将矩阵分解为对称部分(拉伸)+ 反对称部分(旋转):
A=A+AT2⏟纯拉伸+A−AT2⏟纯旋转 A = \underbrace{\frac{A+A^T}{2}}{\text{纯拉伸}} + \underbrace{\frac{A-A^T}{2}}{\text{纯旋转}} A=纯拉伸 2A+AT+纯旋转 2A−AT
- c − b > 0:逆时针扭转 ↺
- c − b < 0:顺时针扭转 ↻
- c = b(对称):不扭转,纯拉伸
- 扭转角度 ≈ (arctanc−ba+d\arctan\frac{c-b}{a+d}arctana+dc−b)
3. 行列式决定"是否翻转"
- det(A) > 0:保持手性
- det(A) < 0:镜像翻转
- det(A) = 0:椭圆退化为线段(矩阵奇异)
实例:A = [[2, 1], [3, 5]]
- 对角线 (2, 5) → 瘦高椭圆,y 方向拉得更长
- c − b = 3 − 1 = +2 > 0 → 逆时针扭转 ≈ 16°
- det = 7 > 0 → 不翻转

大概得感觉:一个瘦高的椭圆,向左上方倾斜约 16°。从上面的示例图我们可以看到大概确实是这样
五、从 2D 到 n D:维度的推广
这套直觉可以无缝推广到高维:
| 维度 | 拉伸自由度 | 旋转平面数 | 旋转自由度 |
|---|---|---|---|
| 2D | 3 | 1(xy) | 1 |
| 3D | 6 | 3(xy, xz, yz) | 3 |
| 4D | 10 | 6 | 6 |
| n D | n(n+1)/2 | n(n−1)/2 | n(n−1)/2 |
3D 中的反对称部分恰好对应一个"角速度向量"------这就是物理学中刚体旋转的数学基础。
六、为什么 SVD 重要?
SVD 不仅仅是一个数学工具,它是众多现代算法的核心:
- 主成分分析(PCA):本质上就是对数据矩阵做 SVD
- 图像压缩:保留前 k 个最大的奇异值,丢弃其余
- 推荐系统:矩阵分解(如 Netflix Prize)
- 最小二乘 :伪逆 (A+=VΣ+UTA^+ = V\Sigma^+U^TA+=VΣ+UT) 直接由 SVD 给出
- 数值稳定性 :条件数 = (σ1/σn\sigma_1 / \sigma_nσ1/σn)
- 大语言模型:LoRA 等低秩适配技术、注意力矩阵的低秩近似
奇异值告诉我们:在所有可能的方向中,矩阵最看重哪些方向,最忽略哪些方向。
七、动手实验:可视化工具说明
下面的可视化工具让你可以:
- 拖动矩阵元素 a, b, c, d,实时看到椭圆的形变
- 同时观察单位圆、椭圆、特征向量、奇异向量、SVD 三步分解
- 对比预设矩阵:纯拉伸、纯旋转、剪切、对称矩阵、奇异矩阵......
- 在 2D 中建立直觉后,再用 3D 模式观察高维推广
建议的探索路径:
-
从 ([[2,0],[0,1]][[2,0],[0,1]][[2,0],[0,1]]) 开始 ------ 纯拉伸,所有箭头共线

-
调到 ([[2,1],[1,2]][[2,1],[1,2]][[2,1],[1,2]]) ------ 对称矩阵,特征向量与奇异向量重合

-
调到 ([[2,1],[3,5]][[2,1],[3,5]][[2,1],[3,5]]) ------ 一般矩阵,三种箭头各不相同
-
调到 ([[0,−1],[1,0]][[0,-1],[1,0]][[0,−1],[1,0]]) ------ 纯旋转,椭圆 = 单位圆

-
调到 ([[1,2],[2,4]][[1,2],[2,4]][[1,2],[2,4]]) ------ 奇异矩阵,椭圆退化为线段

带着这些问题去玩:
- 什么时候特征向量与奇异向量重合?
- 什么时候椭圆"翻面"了?(提示:看 det 的符号)
- 调到什么矩阵时,椭圆变成圆?
接下来的代码实现部分 :

源码如下
jsx
import React, { useState, useEffect, useRef } from 'react';
import { Play, RotateCcw, SkipForward } from 'lucide-react';
export default function MatrixVisualizer() {
const [aVal, setA] = useState(2);
const [bVal, setB] = useState(1);
const [cVal, setC] = useState(0);
const [dVal, setD] = useState(3);
const [tVal, setT] = useState(1);
const [playing, setPlaying] = useState(false);
const [showSquare, setShowSquare] = useState(true);
const [showCircle, setShowCircle] = useState(true);
const [showEigen, setShowEigen] = useState(false);
const [showSvd, setShowSvd] = useState(true);
const rafRef = useRef(null);
useEffect(() => {
if (!playing) return;
let start = null;
setT(0);
const step = (ts) => {
if (start === null) start = ts;
const elapsed = (ts - start) / 1800;
const newT = Math.min(1, elapsed);
setT(newT);
if (newT < 1) {
rafRef.current = requestAnimationFrame(step);
} else {
setPlaying(false);
}
};
rafRef.current = requestAnimationFrame(step);
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, [playing]);
const m00 = 1 + (aVal - 1) * tVal;
const m01 = bVal * tVal;
const m10 = cVal * tVal;
const m11 = 1 + (dVal - 1) * tVal;
const apply = (xx, yy) => [m00 * xx + m01 * yy, m10 * xx + m11 * yy];
const computeSVD = (p, q, r, s) => {
const ata00 = p * p + r * r;
const ata11 = q * q + s * s;
const ata01 = p * q + r * s;
const trace = ata00 + ata11;
const determinant = ata00 * ata11 - ata01 * ata01;
const discr = Math.max(0, trace * trace - 4 * determinant);
const lam1 = (trace + Math.sqrt(discr)) / 2;
const lam2 = Math.max(0, (trace - Math.sqrt(discr)) / 2);
const sig1 = Math.sqrt(Math.max(0, lam1));
const sig2 = Math.sqrt(lam2);
let vx, vy;
if (Math.abs(ata01) > 1e-9) {
vx = ata01;
vy = lam1 - ata00;
const norm = Math.hypot(vx, vy);
if (norm > 1e-9) {
vx /= norm;
vy /= norm;
} else {
vx = 1;
vy = 0;
}
} else {
if (ata00 >= ata11) {
vx = 1;
vy = 0;
} else {
vx = 0;
vy = 1;
}
}
return {
s1: sig1,
s2: sig2,
v1x: vx,
v1y: vy,
v2x: -vy,
v2y: vx,
};
};
const svdA = computeSVD(aVal, bVal, cVal, dVal);
const svdM = computeSVD(m00, m01, m10, m11);
const detA = aVal * dVal - bVal * cVal;
const trA = aVal + dVal;
const discA = trA * trA - 4 * detA;
let eigVals = null;
let eigVec1 = null;
let eigVec2 = null;
if (discA >= -1e-9) {
const sq = Math.sqrt(Math.max(0, discA));
const lam1 = (trA + sq) / 2;
const lam2 = (trA - sq) / 2;
const findVec = (lam) => {
let xx, yy;
if (Math.abs(bVal) > 1e-9) {
xx = bVal;
yy = lam - aVal;
} else if (Math.abs(cVal) > 1e-9) {
xx = lam - dVal;
yy = cVal;
} else {
if (Math.abs(lam - aVal) < 1e-9) {
xx = 1;
yy = 0;
} else {
xx = 0;
yy = 1;
}
}
const norm = Math.hypot(xx, yy);
if (norm > 1e-9) return [xx / norm, yy / norm];
return [1, 0];
};
eigVals = [lam1, lam2];
eigVec1 = findVec(lam1);
eigVec2 = findVec(lam2);
}
const SIZE = 480;
const SCALE = 30;
const CENTER = SIZE / 2;
const toSvg = (xx, yy) => [CENTER + xx * SCALE, CENTER - yy * SCALE];
const safeNum = (val) => (Number.isFinite(val) ? val : 0);
const gridLines = [];
for (let i = -7; i <= 7; i++) {
const aPt = apply(i, -7);
const bPt = apply(i, 7);
gridLines.push({ x1: aPt[0], y1: aPt[1], x2: bPt[0], y2: bPt[1], axis: i === 0 });
const cPt = apply(-7, i);
const dPt = apply(7, i);
gridLines.push({ x1: cPt[0], y1: cPt[1], x2: dPt[0], y2: dPt[1], axis: i === 0 });
}
const N = 80;
let circlePath = '';
for (let i = 0; i <= N; i++) {
const th = (i / N) * Math.PI * 2;
const pt = apply(Math.cos(th), Math.sin(th));
const sv = toSvg(pt[0], pt[1]);
circlePath += (i === 0 ? 'M' : 'L') + ' ' + sv[0].toFixed(1) + ' ' + sv[1].toFixed(1) + ' ';
}
const sqCornersData = [[0, 0], [1, 0], [1, 1], [0, 1]];
const sqCorners = [];
for (let i = 0; i < sqCornersData.length; i++) {
const pt = apply(sqCornersData[i][0], sqCornersData[i][1]);
sqCorners.push(toSvg(pt[0], pt[1]));
}
const sqPath =
sqCorners.map((p, i) => (i === 0 ? 'M' : 'L') + ' ' + p[0] + ' ' + p[1]).join(' ') + ' Z';
const ihatPt = apply(1, 0);
const jhatPt = apply(0, 1);
const Arrow = ({ fromX, fromY, toX, toY, color, width }) => {
if (!Number.isFinite(toX) || !Number.isFinite(toY)) return null;
const sv1 = toSvg(fromX, fromY);
const sv2 = toSvg(toX, toY);
const dx = sv2[0] - sv1[0];
const dy = sv2[1] - sv1[1];
const len = Math.hypot(dx, dy);
if (len < 2) return null;
const ux = dx / len;
const uy = dy / len;
const headLen = 10;
const headW = 6;
const baseX = sv2[0] - ux * headLen;
const baseY = sv2[1] - uy * headLen;
const px = -uy;
const py = ux;
return (
<g>
<line
x1={sv1[0]}
y1={sv1[1]}
x2={baseX}
y2={baseY}
stroke={color}
strokeWidth={width}
strokeLinecap="round"
/>
<polygon
points={
sv2[0] + ',' + sv2[1] + ' ' +
(baseX + px * headW) + ',' + (baseY + py * headW) + ' ' +
(baseX - px * headW) + ',' + (baseY - py * headW)
}
fill={color}
/>
</g>
);
};
const presets = [
{ name: '纯拉伸', vals: [2, 0, 0, 0.5] },
{ name: '旋转 90°', vals: [0, -1, 1, 0] },
{ name: '剪切', vals: [1, 1, 0, 1] },
{ name: '降秩', vals: [1, 2, 2, 4] },
{ name: '反射', vals: [1, 0, 0, -1] },
];
const Mv1 = apply(svdM.v1x, svdM.v1y);
const Mv2 = apply(svdM.v2x, svdM.v2y);
const inputCell =
'bg-slate-700 rounded px-2 py-1.5 text-center font-mono text-base focus:outline-none focus:ring-2 focus:ring-blue-500 w-full';
const eigenElements = [];
if (showEigen && eigVec1 && eigVec2) {
const vectors = [
{ vec: eigVec1, color: '#a855f7' },
{ vec: eigVec2, color: '#d8b4fe' },
];
for (let i = 0; i < vectors.length; i++) {
const item = vectors[i];
const evx = item.vec[0];
const evy = item.vec[1];
const Llen = 6;
const inS = toSvg(-evx * Llen, -evy * Llen);
const inE = toSvg(evx * Llen, evy * Llen);
const tStart = apply(-evx * Llen, -evy * Llen);
const tEnd = apply(evx * Llen, evy * Llen);
const outS = toSvg(tStart[0], tStart[1]);
const outE = toSvg(tEnd[0], tEnd[1]);
eigenElements.push(
<g key={'eig-' + i}>
<line
x1={inS[0]} y1={inS[1]} x2={inE[0]} y2={inE[1]}
stroke={item.color} strokeWidth={1} strokeDasharray="4,3" opacity={0.4}
/>
<line
x1={outS[0]} y1={outS[1]} x2={outE[0]} y2={outE[1]}
stroke={item.color} strokeWidth={2.5}
/>
</g>
);
}
}
let svdDashed = null;
if (showSvd) {
const Llen = 5;
const i1 = toSvg(-svdM.v1x * Llen, -svdM.v1y * Llen);
const i2 = toSvg(svdM.v1x * Llen, svdM.v1y * Llen);
const j1 = toSvg(-svdM.v2x * Llen, -svdM.v2y * Llen);
const j2 = toSvg(svdM.v2x * Llen, svdM.v2y * Llen);
svdDashed = (
<g>
<line x1={i1[0]} y1={i1[1]} x2={i2[0]} y2={i2[1]}
stroke="#fb7185" strokeWidth={1} strokeDasharray="4,3" opacity={0.35} />
<line x1={j1[0]} y1={j1[1]} x2={j2[0]} y2={j2[1]}
stroke="#fda4af" strokeWidth={1} strokeDasharray="4,3" opacity={0.35} />
</g>
);
}
// 把 LaTeX 字符串放到这里,避免 JSX 把 {v} 当成表达式
const latexV1V2 = "\\(\\mathbf{v}_1, \\mathbf{v}_2\\)";
const latexMvi = "\\(M\\mathbf{v}_i = \\sigma_i \\mathbf{u}_i\\)";
return (
<div className="min-h-screen bg-slate-900 text-slate-100 p-3 md:p-6">
<div className="max-w-5xl mx-auto">
<h1 className="text-xl md:text-2xl font-bold mb-1">SVD 主轴 = 椭圆长短轴</h1>
<p className="text-slate-400 text-xs md:text-sm mb-4">
每帧重算 M(t) 的 SVD,玫红箭头始终对齐当前椭圆的长短轴。
</p>
<div className="grid md:grid-cols-5 gap-3 mb-3">
<div className="bg-slate-800 rounded-lg p-3 md:col-span-2">
<div className="text-xs text-slate-400 mb-2">矩阵 A</div>
<div className="grid grid-cols-2 gap-2">
<input type="number" step="0.5" value={aVal}
onChange={(e) => setA(safeNum(parseFloat(e.target.value)))}
className={inputCell} />
<input type="number" step="0.5" value={bVal}
onChange={(e) => setB(safeNum(parseFloat(e.target.value)))}
className={inputCell} />
<input type="number" step="0.5" value={cVal}
onChange={(e) => setC(safeNum(parseFloat(e.target.value)))}
className={inputCell} />
<input type="number" step="0.5" value={dVal}
onChange={(e) => setD(safeNum(parseFloat(e.target.value)))}
className={inputCell} />
</div>
</div>
<div className="bg-slate-800 rounded-lg p-3 md:col-span-3">
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<div className="text-slate-400 text-xs">det(A)</div>
<div className="text-lg font-mono text-cyan-400">{detA.toFixed(2)}</div>
</div>
<div>
<div className="text-slate-400 text-xs">特征值 λ</div>
<div className="text-lg font-mono text-purple-400">
{eigVals
? eigVals[0].toFixed(2) + ', ' + eigVals[1].toFixed(2)
: '复数'}
</div>
</div>
<div>
<div className="text-slate-400 text-xs">奇异值 σ (A)</div>
<div className="text-lg font-mono text-rose-400">
{svdA.s1.toFixed(2)}, {svdA.s2.toFixed(2)}
</div>
</div>
<div>
<div className="text-slate-400 text-xs">σ₁ × σ₂</div>
<div className="text-lg font-mono text-amber-400">
{(svdA.s1 * svdA.s2).toFixed(2)}
</div>
<div className="text-xs text-slate-500">= |det| ✓</div>
</div>
</div>
</div>
</div>
<div className="flex flex-wrap gap-2 mb-3">
{presets.map((p) => (
<button
key={p.name}
onClick={() => {
setA(p.vals[0]);
setB(p.vals[1]);
setC(p.vals[2]);
setD(p.vals[3]);
setT(1);
setPlaying(false);
}}
className="px-3 py-1 rounded text-xs bg-slate-700 hover:bg-slate-600"
>
{p.name}
</button>
))}
</div>
<div className="flex flex-wrap gap-2 mb-3">
<button onClick={() => setShowSquare(!showSquare)}
className={'px-3 py-1.5 rounded text-xs ' +
(showSquare ? 'bg-cyan-600 text-white' : 'bg-slate-700 text-slate-400')}>
单位正方形
</button>
<button onClick={() => setShowCircle(!showCircle)}
className={'px-3 py-1.5 rounded text-xs ' +
(showCircle ? 'bg-amber-600 text-white' : 'bg-slate-700 text-slate-400')}>
单位圆 → 椭圆
</button>
<button onClick={() => setShowEigen(!showEigen)}
className={'px-3 py-1.5 rounded text-xs ' +
(showEigen ? 'bg-purple-600 text-white' : 'bg-slate-700 text-slate-400')}>
特征向量
</button>
<button onClick={() => setShowSvd(!showSvd)}
className={'px-3 py-1.5 rounded text-xs ' +
(showSvd ? 'bg-rose-600 text-white' : 'bg-slate-700 text-slate-400')}>
SVD 主轴
</button>
</div>
<div className="bg-slate-800 rounded-lg p-2 flex justify-center mb-3">
<svg viewBox={'0 0 ' + SIZE + ' ' + SIZE} className="w-full max-w-lg bg-slate-950 rounded">
<g opacity={0.35}>
{gridLines.map((ln, i) => {
const sv1 = toSvg(ln.x1, ln.y1);
const sv2 = toSvg(ln.x2, ln.y2);
return (
<line key={i} x1={sv1[0]} y1={sv1[1]} x2={sv2[0]} y2={sv2[1]}
stroke={ln.axis ? '#64748b' : '#334155'}
strokeWidth={ln.axis ? 1 : 0.5} />
);
})}
</g>
<line x1={0} y1={CENTER} x2={SIZE} y2={CENTER} stroke="#475569" strokeWidth={0.5} strokeDasharray="2,4" />
<line x1={CENTER} y1={0} x2={CENTER} y2={SIZE} stroke="#475569" strokeWidth={0.5} strokeDasharray="2,4" />
{showSquare && (
<path d={sqPath} fill="rgba(6,182,212,0.18)" stroke="#06b6d4" strokeWidth={2} />
)}
{showCircle && (
<path d={circlePath} fill="none" stroke="#f59e0b" strokeWidth={2} />
)}
{eigenElements}
{svdDashed}
{showSvd && (
<g>
<Arrow fromX={0} fromY={0} toX={Mv1[0]} toY={Mv1[1]} color="#fb7185" width={3.5} />
<Arrow fromX={0} fromY={0} toX={Mv2[0]} toY={Mv2[1]} color="#fda4af" width={3.5} />
</g>
)}
<Arrow fromX={0} fromY={0} toX={ihatPt[0]} toY={ihatPt[1]} color="#ef4444" width={3.5} />
<Arrow fromX={0} fromY={0} toX={jhatPt[0]} toY={jhatPt[1]} color="#22c55e" width={3.5} />
<circle cx={CENTER} cy={CENTER} r={3} fill="#f1f5f9" />
</svg>
</div>
<div className="bg-slate-800 rounded-lg p-3 mb-3">
<div className="flex items-center gap-2 mb-3 flex-wrap">
<button onClick={() => setPlaying(true)} disabled={playing}
className="bg-blue-600 hover:bg-blue-500 disabled:opacity-50 px-3 py-2 rounded flex items-center gap-2 text-sm">
<Play size={14} /> 播放
</button>
<button onClick={() => { setPlaying(false); setT(0); }}
className="bg-slate-700 hover:bg-slate-600 px-3 py-2 rounded flex items-center gap-2 text-sm">
<RotateCcw size={14} /> 起点
</button>
<button onClick={() => { setPlaying(false); setT(1); }}
className="bg-slate-700 hover:bg-slate-600 px-3 py-2 rounded flex items-center gap-2 text-sm">
<SkipForward size={14} /> 终点
</button>
<span className="text-xs text-slate-400 font-mono ml-auto">t = {tVal.toFixed(2)}</span>
</div>
<input type="range" min="0" max="1" step="0.01" value={tVal}
onChange={(e) => { setPlaying(false); setT(parseFloat(e.target.value)); }}
className="w-full accent-blue-500" />
</div>
<div className="bg-slate-800 rounded-lg p-3 text-xs md:text-sm space-y-1.5">
<div className="font-semibold text-slate-200 mb-1">读图:</div>
<div>
<span className="text-rose-300 font-semibold">玫红虚线</span>
:当前 M(t) 的右奇异向量 {latexV1V2}(输入空间正交)
</div>
<div>
<span className="text-rose-400 font-semibold">玫红实箭头</span>
:{latexMvi} --- 椭圆的长轴 / 短轴
</div>
<div className="pt-2 border-t border-slate-700 mt-2 text-slate-400">
拖动滑块时虚线会随之旋转(每个 M(t) 都有自己的最优正交输入),但实箭头始终对齐椭圆主轴 ✓
</div>
</div>
</div>
</div>
);
}