diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml
new file mode 100644
index 0000000..7d11101
--- /dev/null
+++ b/.github/workflows/deploy-pages.yml
@@ -0,0 +1,42 @@
+name: Deploy to GitHub Pages
+
+on:
+ push:
+ branches:
+ - main
+ - claude/linear-algebra-visualization-al3et9
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+# 同一时间只允许一个部署
+concurrency:
+ group: pages
+ cancel-in-progress: true
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Configure Pages
+ uses: actions/configure-pages@v5
+ with:
+ enablement: true
+
+ - name: Upload site artifact
+ uses: actions/upload-pages-artifact@v3
+ with:
+ path: .
+
+ - name: Deploy
+ id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..416ac00
--- /dev/null
+++ b/README.md
@@ -0,0 +1,40 @@
+# 线性代数可视化 · 交互教学网站
+
+用交互动画建立线性代数的几何直觉。零构建、纯静态,打开即用,可部署到任意静态托管(GitHub Pages / Vercel / Netlify)。
+
+## 课程内容
+
+1. **向量基础** — 拖动箭头,理解分量、模长与方向角
+2. **向量加法** — 首尾相接与平行四边形法则
+3. **线性组合 · 张成空间** — 调节系数 a、b 扫过张成空间,观察线性相关时的塌缩
+4. **矩阵即变换** — 一个 2×2 矩阵如何扭曲整个网格,行列式 = 面积缩放(带动画)
+5. **特征向量与特征值** — 寻找变换中方向不变的特殊方向
+
+## 本地运行
+
+纯静态文件,无需依赖。任选一种方式起一个本地服务器(因为用了 ES Module,不能直接 `file://` 打开):
+
+```bash
+python3 -m http.server 8000
+# 然后浏览器打开 http://localhost:8000
+```
+
+## 技术说明
+
+- 纯 HTML / CSS / 原生 JavaScript(ES Modules),无打包、无框架依赖。
+- `js/plane.js`:自写的二维坐标平面引擎,负责坐标变换、网格/坐标轴绘制、向量绘制、可拖拽控制点。所有课程复用它。
+- `js/lessons/*.js`:每节课一个模块,导出 `meta` 与 `mount(root)`。
+- `js/app.js`:哈希路由,把课程挂载到主区域。
+
+## 如何扩展新课程
+
+1. 在 `js/lessons/` 新建 `xxx.js`,导出 `meta = { id, title, subtitle }` 和 `mount(root)`。
+2. 在 `js/app.js` 顶部 import 并加入 `lessons` 数组即可,导航会自动生成。
+
+## 后续可以做的方向
+
+- 点积 / 叉积的几何意义、投影
+- 高斯消元 / 求解线性方程组的可视化
+- 三维向量与变换(可接入 WebGL / three.js)
+- 矩阵乘法 = 变换的复合(动画串联)
+- 每节课配练习题与即时判分
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..3c7d8da
--- /dev/null
+++ b/index.html
@@ -0,0 +1,30 @@
+
+
+
+
+
+ 线性代数可视化 · 交互教学
+
+
+
+
+
+
+
+
+
+
+
diff --git a/js/app.js b/js/app.js
new file mode 100644
index 0000000..09341b7
--- /dev/null
+++ b/js/app.js
@@ -0,0 +1,41 @@
+// app.js — 简单的哈希路由,把各课程挂载到主区域
+import * as vectors from './lessons/vectors.js';
+import * as addition from './lessons/addition.js';
+import * as combination from './lessons/combination.js';
+import * as transform from './lessons/transform.js';
+import * as eigen from './lessons/eigen.js';
+
+const lessons = [vectors, addition, combination, transform, eigen];
+const byId = Object.fromEntries(lessons.map(l => [l.meta.id, l]));
+
+const nav = document.getElementById('nav');
+const main = document.getElementById('main');
+
+// 构建侧边导航
+lessons.forEach((l, i) => {
+ const a = document.createElement('a');
+ a.href = '#' + l.meta.id;
+ a.className = 'nav-item';
+ a.innerHTML = `${i + 1}
+ ${l.meta.title}${l.meta.subtitle}`;
+ a.dataset.id = l.meta.id;
+ nav.appendChild(a);
+});
+
+function route() {
+ const id = location.hash.slice(1) || lessons[0].meta.id;
+ const lesson = byId[id] || lessons[0];
+ document.querySelectorAll('.nav-item').forEach(el =>
+ el.classList.toggle('active', el.dataset.id === lesson.meta.id));
+ main.innerHTML = '';
+ const head = document.createElement('header');
+ head.className = 'lesson-head';
+ head.innerHTML = `${lesson.meta.title}
${lesson.meta.subtitle}
`;
+ main.appendChild(head);
+ const body = document.createElement('div');
+ main.appendChild(body);
+ lesson.mount(body);
+}
+
+window.addEventListener('hashchange', route);
+route();
diff --git a/js/lessons/addition.js b/js/lessons/addition.js
new file mode 100644
index 0000000..c03d260
--- /dev/null
+++ b/js/lessons/addition.js
@@ -0,0 +1,56 @@
+// 课程 2:向量加法 —— 两个可拖拽向量,首尾相接 / 平行四边形法则
+import { CartesianPlane } from '../plane.js';
+
+export const meta = {
+ id: 'addition',
+ title: '向量加法',
+ subtitle: '首尾相接,看 v + w 如何形成',
+};
+
+export function mount(root) {
+ root.innerHTML = `
+ `;
+
+ const plane = new CartesianPlane(document.getElementById('cv'), { unit: 50 });
+ let v = [2, 1], w = [1, 2];
+ const snap = (n) => Math.round(n * 2) / 2;
+
+ const hv = plane.addHandle(v[0], v[1], '#5cc8ff', (x, y) => { v = [snap(x), snap(y)]; hv.x = v[0]; hv.y = v[1]; update(); });
+ const hw = plane.addHandle(w[0], w[1], '#7ee6a0', (x, y) => { w = [snap(x), snap(y)]; hw.x = w[0]; hw.y = w[1]; update(); });
+
+ plane.onRender((p) => {
+ const sum = [v[0] + w[0], v[1] + w[1]];
+ if (document.getElementById('para')?.checked) {
+ p.polygon([[0, 0], v, sum, w], 'rgba(255,210,120,0.12)');
+ }
+ // w 从 v 的头部出发(首尾相接)
+ p.vector(sum[0], sum[1], 'rgba(120,230,160,0.55)', { from: v, width: 2 });
+ p.vector(v[0], v[1], '#5cc8ff', { label: 'v' });
+ p.vector(w[0], w[1], '#7ee6a0', { label: 'w' });
+ p.vector(sum[0], sum[1], '#ffd27a', { label: 'v+w', width: 3.5 });
+ });
+
+ function update() {
+ const sum = [v[0] + w[0], v[1] + w[1]];
+ document.getElementById('r-v').textContent = `(${v[0]}, ${v[1]})`;
+ document.getElementById('r-w').textContent = `(${w[0]}, ${w[1]})`;
+ document.getElementById('r-sum').textContent = `(${sum[0]}, ${sum[1]})`;
+ plane.render();
+ }
+ document.getElementById('para').addEventListener('change', () => plane.render());
+ update();
+}
diff --git a/js/lessons/combination.js b/js/lessons/combination.js
new file mode 100644
index 0000000..0e060b4
--- /dev/null
+++ b/js/lessons/combination.js
@@ -0,0 +1,71 @@
+// 课程 3:线性组合与张成空间 —— 用两个基向量 + 系数滑块到达任意点
+import { CartesianPlane } from '../plane.js';
+
+export const meta = {
+ id: 'combination',
+ title: '线性组合 · 张成空间',
+ subtitle: '调节系数 a、b,到达平面上任意位置',
+};
+
+export function mount(root) {
+ root.innerHTML = `
+ `;
+
+ const plane = new CartesianPlane(document.getElementById('cv'), { unit: 45 });
+ let v = [2, 1], w = [1, 2], a = 1, b = 1;
+ const snap = (n) => Math.round(n * 2) / 2;
+
+ const hv = plane.addHandle(v[0], v[1], '#5cc8ff', (x, y) => { v = [snap(x), snap(y)]; hv.x = v[0]; hv.y = v[1]; update(); });
+ const hw = plane.addHandle(w[0], w[1], '#7ee6a0', (x, y) => { w = [snap(x), snap(y)]; hw.x = w[0]; hw.y = w[1]; update(); });
+
+ plane.onRender((p) => {
+ const cross = v[0] * w[1] - v[1] * w[0];
+ // 张成空间提示:共线 -> 画直线;否则淡淡铺满
+ if (Math.abs(cross) < 1e-6) {
+ const d = Math.hypot(v[0], v[1]) > 1e-6 ? v : w;
+ if (Math.hypot(d[0], d[1]) > 1e-6) {
+ p.line(-d[0] * 20, -d[1] * 20, d[0] * 20, d[1] * 20, 'rgba(255,210,120,0.25)', 8);
+ }
+ }
+ const av = [a * v[0], a * v[1]];
+ const pt = [a * v[0] + b * w[0], a * v[1] + b * w[1]];
+ // 组合路径:先 a·v,再 +b·w
+ p.vector(av[0], av[1], 'rgba(92,200,255,0.5)', { width: 2 });
+ p.vector(pt[0], pt[1], 'rgba(120,230,160,0.5)', { from: av, width: 2 });
+ p.vector(v[0], v[1], '#5cc8ff', { label: 'v' });
+ p.vector(w[0], w[1], '#7ee6a0', { label: 'w' });
+ p.point(pt[0], pt[1], '#ffd27a', 6);
+ });
+
+ function update() {
+ a = parseFloat(document.getElementById('a').value);
+ b = parseFloat(document.getElementById('b').value);
+ document.getElementById('va').textContent = a.toFixed(1);
+ document.getElementById('vb').textContent = b.toFixed(1);
+ const pt = [a * v[0] + b * w[0], a * v[1] + b * w[1]];
+ document.getElementById('r-pt').textContent = `(${pt[0].toFixed(1)}, ${pt[1].toFixed(1)})`;
+ const cross = v[0] * w[1] - v[1] * w[0];
+ document.getElementById('r-span').textContent =
+ Math.abs(cross) < 1e-6 ? '一条直线(线性相关)' : '整个平面';
+ plane.render();
+ }
+ document.getElementById('a').addEventListener('input', update);
+ document.getElementById('b').addEventListener('input', update);
+ update();
+}
diff --git a/js/lessons/eigen.js b/js/lessons/eigen.js
new file mode 100644
index 0000000..f200d6a
--- /dev/null
+++ b/js/lessons/eigen.js
@@ -0,0 +1,90 @@
+// 课程 5:特征向量 —— 哪些方向在变换后只被拉伸、不改变方向
+import { CartesianPlane, matVec, det, eigen2 } from '../plane.js';
+
+export const meta = {
+ id: 'eigen',
+ title: '特征向量与特征值',
+ subtitle: '寻找变换中「方向不变」的特殊方向',
+};
+
+export function mount(root) {
+ root.innerHTML = `
+
+
+
+
`;
+
+ const plane = new CartesianPlane(document.getElementById('cv'), { unit: 42 });
+ let M = [[2, 1], [1, 2]];
+ let ang = 0.6; // 测试向量角度
+
+ const readM = () => {
+ M = [[+document.getElementById('m00').value || 0, +document.getElementById('m01').value || 0],
+ [+document.getElementById('m10').value || 0, +document.getElementById('m11').value || 0]];
+ };
+
+ const tip = [Math.cos(ang) * 3, Math.sin(ang) * 3];
+ const h = plane.addHandle(tip[0], tip[1], '#fff', (x, y) => {
+ ang = Math.atan2(y, x);
+ h.x = Math.cos(ang) * 3; h.y = Math.sin(ang) * 3;
+ update();
+ });
+
+ plane.onRender((p) => {
+ const eigs = eigen2(M);
+ // 特征方向:黄色长虚线
+ for (const e of eigs) {
+ const v = e.vec;
+ p.line(-v[0] * 20, -v[1] * 20, v[0] * 20, v[1] * 20, 'rgba(255,210,120,0.35)', 2, [8, 6]);
+ }
+ // 测试向量与它的变换
+ const tv = [Math.cos(ang) * 3, Math.sin(ang) * 3];
+ const Mv = matVec(M, tv);
+ p.vector(Mv[0], Mv[1], '#ff9a5c', { label: 'M·v', width: 3 });
+ p.vector(tv[0], tv[1], '#5cc8ff', { label: 'v', width: 3 });
+ });
+
+ function update() {
+ readM();
+ const eigs = eigen2(M);
+ const out = document.getElementById('eig-out');
+ if (!eigs.length) {
+ out.innerHTML = '实特征值无(纯旋转)
';
+ } else {
+ out.innerHTML = eigs.map((e, i) =>
+ `λ${i + 1}${e.value.toFixed(2)} ,方向 (${e.vec[0].toFixed(2)}, ${e.vec[1].toFixed(2)})
`
+ ).join('') + `det${det(M).toFixed(2)}
`;
+ }
+ plane.render();
+ }
+
+ root.querySelectorAll('[data-preset]').forEach(btn => {
+ btn.addEventListener('click', () => {
+ const [a, b, c, d] = btn.dataset.preset.split(',');
+ document.getElementById('m00').value = a; document.getElementById('m01').value = b;
+ document.getElementById('m10').value = c; document.getElementById('m11').value = d;
+ update();
+ });
+ });
+ ['m00', 'm01', 'm10', 'm11'].forEach(id =>
+ document.getElementById(id).addEventListener('input', update));
+ update();
+}
diff --git a/js/lessons/transform.js b/js/lessons/transform.js
new file mode 100644
index 0000000..8be8d69
--- /dev/null
+++ b/js/lessons/transform.js
@@ -0,0 +1,117 @@
+// 课程 4:矩阵即变换 —— 一个 2x2 矩阵如何把整个平面网格扭曲,行列式 = 面积缩放
+import { CartesianPlane, det } from '../plane.js';
+
+export const meta = {
+ id: 'transform',
+ title: '矩阵即变换',
+ subtitle: '看一个 2×2 矩阵如何扭曲整个空间',
+};
+
+export function mount(root) {
+ root.innerHTML = `
+
+
+
+
`;
+
+ const plane = new CartesianPlane(document.getElementById('cv'), { unit: 45, showGrid: false });
+ let M = [[1, 0], [0, 1]];
+ let t = 1; // 动画插值 0->1(从单位阵到 M)
+
+ const ids = ['m00', 'm01', 'm10', 'm11'];
+ const readM = () => {
+ M = [[+document.getElementById('m00').value || 0, +document.getElementById('m01').value || 0],
+ [+document.getElementById('m10').value || 0, +document.getElementById('m11').value || 0]];
+ };
+
+ // 当前插值矩阵(单位阵 -> M)
+ const curM = () => {
+ const I = [[1, 0], [0, 1]];
+ return [[I[0][0] + (M[0][0] - 1) * t, I[0][1] + M[0][1] * t],
+ [I[1][0] + M[1][0] * t, I[1][1] + (M[1][1] - 1) * t]];
+ };
+ const apply = (m, x, y) => [m[0][0] * x + m[0][1] * y, m[1][0] * x + m[1][1] * y];
+
+ plane.onRender((p) => {
+ const m = curM();
+ const N = 8;
+ // 变换后的网格线
+ for (let i = -N; i <= N; i++) {
+ const c = i === 0 ? 'rgba(150,170,210,0.5)' : 'rgba(120,140,180,0.22)';
+ // 竖线 x=i
+ let a1 = apply(m, i, -N), b1 = apply(m, i, N);
+ p.line(a1[0], a1[1], b1[0], b1[1], c, i === 0 ? 1.8 : 1);
+ // 横线 y=i
+ let a2 = apply(m, -N, i), b2 = apply(m, N, i);
+ p.line(a2[0], a2[1], b2[0], b2[1], c, i === 0 ? 1.8 : 1);
+ }
+ // 变换后的单位正方形(面积 = |det|)
+ const sq = [[0, 0], [1, 0], [1, 1], [0, 1]].map(pt => apply(m, pt[0], pt[1]));
+ p.polygon(sq, det(m) >= 0 ? 'rgba(255,210,120,0.18)' : 'rgba(255,120,120,0.20)');
+ // 基向量
+ const i = apply(m, 1, 0), j = apply(m, 0, 1);
+ p.vector(i[0], i[1], '#5cc8ff', { label: 'î' });
+ p.vector(j[0], j[1], '#7ee6a0', { label: 'ĵ' });
+ });
+
+ function update() {
+ readM();
+ const d = det(M);
+ document.getElementById('r-det').textContent = d.toFixed(2);
+ document.getElementById('r-area').textContent = '×' + Math.abs(d).toFixed(2);
+ plane.render();
+ }
+
+ ids.forEach(id => document.getElementById(id).addEventListener('input', () => { t = 1; update(); }));
+ root.querySelectorAll('[data-preset]').forEach(btn => {
+ btn.addEventListener('click', () => {
+ const [a, b, c, d] = btn.dataset.preset.split(',');
+ document.getElementById('m00').value = a; document.getElementById('m01').value = b;
+ document.getElementById('m10').value = c; document.getElementById('m11').value = d;
+ animate();
+ });
+ });
+ document.getElementById('play').addEventListener('click', animate);
+
+ let raf = null;
+ function animate() {
+ readM();
+ cancelAnimationFrame(raf);
+ let start = null;
+ const dur = 900;
+ const step = (ts) => {
+ if (start === null) start = ts;
+ const k = Math.min(1, (ts - start) / dur);
+ t = k < 0.5 ? 2 * k * k : 1 - Math.pow(-2 * k + 2, 2) / 2; // easeInOut
+ update();
+ if (k < 1) raf = requestAnimationFrame(step);
+ else { t = 1; update(); }
+ };
+ t = 0;
+ raf = requestAnimationFrame(step);
+ }
+
+ update();
+}
diff --git a/js/lessons/vectors.js b/js/lessons/vectors.js
new file mode 100644
index 0000000..5c1b13c
--- /dev/null
+++ b/js/lessons/vectors.js
@@ -0,0 +1,53 @@
+// 课程 1:向量基础 —— 拖动向量,理解分量、模长、夹角
+import { CartesianPlane } from '../plane.js';
+
+export const meta = {
+ id: 'vectors',
+ title: '向量基础',
+ subtitle: '拖动箭头,感受分量、模长与方向',
+};
+
+export function mount(root) {
+ root.innerHTML = `
+
+
+
+
`;
+
+ const plane = new CartesianPlane(document.getElementById('cv'), { unit: 50 });
+ let v = [3, 2];
+
+ const h = plane.addHandle(v[0], v[1], '#5cc8ff', (x, y) => {
+ v = [Math.round(x * 2) / 2, Math.round(y * 2) / 2]; // 吸附到 0.5 网格
+ h.x = v[0]; h.y = v[1];
+ update();
+ });
+
+ plane.onRender((p) => {
+ // 分量虚线
+ p.line(0, 0, v[0], 0, 'rgba(255,170,90,0.7)', 2, [6, 5]);
+ p.line(v[0], 0, v[0], v[1], 'rgba(120,230,160,0.7)', 2, [6, 5]);
+ p.vector(v[0], v[1], '#5cc8ff', { label: 'v', width: 3 });
+ });
+
+ function update() {
+ const len = Math.hypot(v[0], v[1]);
+ const ang = (Math.atan2(v[1], v[0]) * 180 / Math.PI);
+ document.getElementById('r-xy').textContent = `(${v[0]}, ${v[1]})`;
+ document.getElementById('r-len').textContent = len.toFixed(2);
+ document.getElementById('r-ang').textContent = ang.toFixed(1) + '°';
+ plane.render();
+ }
+ update();
+}
diff --git a/js/plane.js b/js/plane.js
new file mode 100644
index 0000000..fd9dc1a
--- /dev/null
+++ b/js/plane.js
@@ -0,0 +1,270 @@
+// plane.js — 一个轻量的二维笛卡尔坐标平面绘制引擎
+// 负责:世界坐标 <-> 屏幕像素 的转换、网格/坐标轴绘制、向量/点/多边形绘制、可拖拽控制点
+// 所有课程演示都复用这个类。
+
+export class CartesianPlane {
+ /**
+ * @param {HTMLCanvasElement} canvas
+ * @param {object} opts { unit: 每个单位对应的像素, showGrid, showAxes }
+ */
+ constructor(canvas, opts = {}) {
+ this.canvas = canvas;
+ this.ctx = canvas.getContext('2d');
+ this.unit = opts.unit || 40; // 1 个单位 = 多少像素
+ this.showGrid = opts.showGrid !== false;
+ this.showAxes = opts.showAxes !== false;
+ this.bg = opts.bg || '#0f1420';
+ this.gridColor = opts.gridColor || 'rgba(120,140,180,0.16)';
+ this.axisColor = opts.axisColor || 'rgba(200,215,245,0.55)';
+
+ this.handles = []; // 可拖拽控制点 { x, y, r, color, onDrag }
+ this._dragging = null;
+ this._drawFns = []; // 注册的绘制回调,每帧依次调用
+
+ this._bindEvents();
+ this.resize();
+ window.addEventListener('resize', () => { this.resize(); this.render(); });
+ }
+
+ resize() {
+ const dpr = window.devicePixelRatio || 1;
+ const rect = this.canvas.getBoundingClientRect();
+ this.w = rect.width;
+ this.h = rect.height;
+ this.canvas.width = Math.round(rect.width * dpr);
+ this.canvas.height = Math.round(rect.height * dpr);
+ this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ // 原点放在画布中心
+ this.ox = this.w / 2;
+ this.oy = this.h / 2;
+ }
+
+ // 世界坐标 -> 屏幕像素(注意 y 轴翻转)
+ toScreen(x, y) {
+ return [this.ox + x * this.unit, this.oy - y * this.unit];
+ }
+ // 屏幕像素 -> 世界坐标
+ toWorld(px, py) {
+ return [(px - this.ox) / this.unit, (this.oy - py) / this.unit];
+ }
+
+ clear() {
+ const { ctx } = this;
+ ctx.fillStyle = this.bg;
+ ctx.fillRect(0, 0, this.w, this.h);
+ }
+
+ drawGrid() {
+ if (!this.showGrid) return;
+ const { ctx } = this;
+ const maxX = Math.ceil(this.ox / this.unit);
+ const maxY = Math.ceil(this.oy / this.unit);
+ ctx.lineWidth = 1;
+ ctx.strokeStyle = this.gridColor;
+ ctx.beginPath();
+ for (let i = -maxX; i <= maxX; i++) {
+ const [sx] = this.toScreen(i, 0);
+ ctx.moveTo(sx, 0); ctx.lineTo(sx, this.h);
+ }
+ for (let j = -maxY; j <= maxY; j++) {
+ const [, sy] = this.toScreen(0, j);
+ ctx.moveTo(0, sy); ctx.lineTo(this.w, sy);
+ }
+ ctx.stroke();
+ }
+
+ drawAxes() {
+ if (!this.showAxes) return;
+ const { ctx } = this;
+ ctx.lineWidth = 1.5;
+ ctx.strokeStyle = this.axisColor;
+ ctx.beginPath();
+ ctx.moveTo(0, this.oy); ctx.lineTo(this.w, this.oy);
+ ctx.moveTo(this.ox, 0); ctx.lineTo(this.ox, this.h);
+ ctx.stroke();
+ // 刻度数字
+ ctx.fillStyle = 'rgba(200,215,245,0.5)';
+ ctx.font = '11px ui-monospace, monospace';
+ const maxX = Math.floor(this.ox / this.unit);
+ const maxY = Math.floor(this.oy / this.unit);
+ ctx.textAlign = 'center';
+ for (let i = -maxX; i <= maxX; i++) {
+ if (i === 0) continue;
+ const [sx, sy] = this.toScreen(i, 0);
+ ctx.fillText(String(i), sx, sy + 14);
+ }
+ ctx.textAlign = 'right';
+ for (let j = -maxY; j <= maxY; j++) {
+ if (j === 0) continue;
+ const [sx, sy] = this.toScreen(0, j);
+ ctx.fillText(String(j), sx - 6, sy + 4);
+ }
+ }
+
+ /** 画一条线段(世界坐标) */
+ line(x1, y1, x2, y2, color = '#888', width = 1.5, dash = null) {
+ const { ctx } = this;
+ const [a, b] = this.toScreen(x1, y1);
+ const [c, d] = this.toScreen(x2, y2);
+ ctx.save();
+ if (dash) ctx.setLineDash(dash);
+ ctx.strokeStyle = color;
+ ctx.lineWidth = width;
+ ctx.beginPath();
+ ctx.moveTo(a, b); ctx.lineTo(c, d);
+ ctx.stroke();
+ ctx.restore();
+ }
+
+ /** 画一个向量(从原点或指定起点出发的箭头) */
+ vector(x, y, color = '#5cc8ff', opts = {}) {
+ const ox = opts.from ? opts.from[0] : 0;
+ const oy = opts.from ? opts.from[1] : 0;
+ const { ctx } = this;
+ const [a, b] = this.toScreen(ox, oy);
+ const [c, d] = this.toScreen(x, y);
+ ctx.save();
+ ctx.strokeStyle = color;
+ ctx.fillStyle = color;
+ ctx.lineWidth = opts.width || 3;
+ ctx.beginPath();
+ ctx.moveTo(a, b); ctx.lineTo(c, d);
+ ctx.stroke();
+ // 箭头
+ const ang = Math.atan2(d - b, c - a);
+ const head = opts.head || 11;
+ ctx.beginPath();
+ ctx.moveTo(c, d);
+ ctx.lineTo(c - head * Math.cos(ang - 0.4), d - head * Math.sin(ang - 0.4));
+ ctx.lineTo(c - head * Math.cos(ang + 0.4), d - head * Math.sin(ang + 0.4));
+ ctx.closePath();
+ ctx.fill();
+ if (opts.label) {
+ ctx.fillStyle = opts.labelColor || color;
+ ctx.font = '600 14px ui-sans-serif, system-ui';
+ ctx.fillText(opts.label, c + 8, d - 8);
+ }
+ ctx.restore();
+ }
+
+ /** 画一个点 */
+ point(x, y, color = '#fff', r = 4) {
+ const { ctx } = this;
+ const [a, b] = this.toScreen(x, y);
+ ctx.fillStyle = color;
+ ctx.beginPath();
+ ctx.arc(a, b, r, 0, Math.PI * 2);
+ ctx.fill();
+ }
+
+ /** 画一个填充多边形(世界坐标点数组),用于单位正方形/平行四边形 */
+ polygon(pts, fill = 'rgba(92,200,255,0.15)', stroke = null) {
+ const { ctx } = this;
+ ctx.save();
+ ctx.beginPath();
+ pts.forEach((p, i) => {
+ const [a, b] = this.toScreen(p[0], p[1]);
+ i === 0 ? ctx.moveTo(a, b) : ctx.lineTo(a, b);
+ });
+ ctx.closePath();
+ if (fill) { ctx.fillStyle = fill; ctx.fill(); }
+ if (stroke) { ctx.strokeStyle = stroke; ctx.lineWidth = 2; ctx.stroke(); }
+ ctx.restore();
+ }
+
+ /** 注册一个可拖拽控制点。onDrag(x,y) 在拖动时被调用 */
+ addHandle(x, y, color, onDrag) {
+ const handle = { x, y, r: 9, color, onDrag };
+ this.handles.push(handle);
+ return handle;
+ }
+ drawHandles() {
+ const { ctx } = this;
+ for (const h of this.handles) {
+ const [a, b] = this.toScreen(h.x, h.y);
+ ctx.save();
+ ctx.fillStyle = h.color;
+ ctx.globalAlpha = 0.9;
+ ctx.beginPath();
+ ctx.arc(a, b, h.r, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.lineWidth = 2;
+ ctx.strokeStyle = 'rgba(255,255,255,0.85)';
+ ctx.stroke();
+ ctx.restore();
+ }
+ }
+
+ _bindEvents() {
+ const getPos = (e) => {
+ const rect = this.canvas.getBoundingClientRect();
+ const t = e.touches ? e.touches[0] : e;
+ return [t.clientX - rect.left, t.clientY - rect.top];
+ };
+ const down = (e) => {
+ const [px, py] = getPos(e);
+ for (const h of this.handles) {
+ const [a, b] = this.toScreen(h.x, h.y);
+ if (Math.hypot(px - a, py - b) <= h.r + 8) {
+ this._dragging = h;
+ e.preventDefault();
+ break;
+ }
+ }
+ };
+ const move = (e) => {
+ if (!this._dragging) return;
+ const [px, py] = getPos(e);
+ const [wx, wy] = this.toWorld(px, py);
+ this._dragging.x = wx;
+ this._dragging.y = wy;
+ this._dragging.onDrag(wx, wy);
+ e.preventDefault();
+ this.render();
+ };
+ const up = () => { this._dragging = null; };
+ this.canvas.addEventListener('mousedown', down);
+ window.addEventListener('mousemove', move);
+ window.addEventListener('mouseup', up);
+ this.canvas.addEventListener('touchstart', down, { passive: false });
+ window.addEventListener('touchmove', move, { passive: false });
+ window.addEventListener('touchend', up);
+ }
+
+ /** 注册每帧绘制内容 */
+ onRender(fn) { this._drawFns.push(fn); }
+
+ render() {
+ this.clear();
+ this.drawGrid();
+ this.drawAxes();
+ for (const fn of this._drawFns) fn(this);
+ this.drawHandles();
+ }
+}
+
+/** 把一个 2x2 矩阵 [[a,b],[c,d]] 作用在向量上 */
+export function matVec(m, v) {
+ return [m[0][0] * v[0] + m[0][1] * v[1], m[1][0] * v[0] + m[1][1] * v[1]];
+}
+export function det(m) { return m[0][0] * m[1][1] - m[0][1] * m[1][0]; }
+
+/** 2x2 实特征值/特征向量(用于演示,返回实特征对) */
+export function eigen2(m) {
+ const a = m[0][0], b = m[0][1], c = m[1][0], d = m[1][1];
+ const tr = a + d, dt = a * d - b * c;
+ const disc = tr * tr - 4 * dt;
+ if (disc < -1e-9) return []; // 复特征值,不在实平面演示
+ const s = Math.sqrt(Math.max(0, disc));
+ const l1 = (tr + s) / 2, l2 = (tr - s) / 2;
+ const vecFor = (l) => {
+ // 求 (M - λI) v = 0 的非零解
+ let vx, vy;
+ if (Math.abs(b) > 1e-9) { vx = b; vy = l - a; }
+ else if (Math.abs(c) > 1e-9) { vx = l - d; vy = c; }
+ else { vx = 1; vy = 0; } // 已是对角阵
+ const n = Math.hypot(vx, vy) || 1;
+ return [vx / n, vy / n];
+ };
+ return [{ value: l1, vec: vecFor(l1) }, { value: l2, vec: vecFor(l2) }];
+}
diff --git a/styles.css b/styles.css
new file mode 100644
index 0000000..464f811
--- /dev/null
+++ b/styles.css
@@ -0,0 +1,156 @@
+:root {
+ --bg: #0b0f17;
+ --panel: #141a26;
+ --panel-2: #1b2331;
+ --line: #263244;
+ --text: #e7edf7;
+ --muted: #93a3bd;
+ --accent: #5cc8ff;
+ --accent-2: #7ee6a0;
+ --warn: #ffd27a;
+}
+
+* { box-sizing: border-box; margin: 0; padding: 0; }
+
+body {
+ font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
+ background: var(--bg);
+ color: var(--text);
+ -webkit-font-smoothing: antialiased;
+}
+
+.app { display: flex; min-height: 100vh; }
+
+/* 侧边栏 */
+.sidebar {
+ width: 280px;
+ flex-shrink: 0;
+ background: var(--panel);
+ border-right: 1px solid var(--line);
+ display: flex;
+ flex-direction: column;
+ padding: 22px 16px;
+ position: sticky;
+ top: 0;
+ height: 100vh;
+}
+.brand { display: flex; align-items: center; gap: 12px; padding: 0 8px 20px; }
+.logo {
+ width: 42px; height: 42px; border-radius: 12px;
+ display: grid; place-items: center;
+ font-size: 24px; font-weight: 700;
+ background: linear-gradient(135deg, var(--accent), var(--accent-2));
+ color: #07101a;
+}
+.brand h1 { font-size: 18px; letter-spacing: .5px; }
+.brand small { color: var(--muted); font-size: 12px; }
+
+#nav { display: flex; flex-direction: column; gap: 6px; flex: 1; }
+.nav-item {
+ display: flex; align-items: center; gap: 12px;
+ padding: 11px 12px; border-radius: 10px;
+ text-decoration: none; color: var(--muted);
+ transition: background .15s, color .15s;
+}
+.nav-item:hover { background: var(--panel-2); color: var(--text); }
+.nav-item.active { background: var(--panel-2); color: var(--text); }
+.nav-item.active .nav-num { background: var(--accent); color: #07101a; }
+.nav-num {
+ width: 26px; height: 26px; border-radius: 8px; flex-shrink: 0;
+ display: grid; place-items: center; font-size: 13px; font-weight: 700;
+ background: var(--line); color: var(--text);
+}
+.nav-item b { font-size: 14px; font-weight: 600; display: block; }
+.nav-item small { font-size: 11.5px; color: var(--muted); display: block; margin-top: 2px; }
+.side-foot { font-size: 11.5px; color: var(--muted); line-height: 1.7; padding: 16px 10px 4px; border-top: 1px solid var(--line); }
+
+/* 主内容 */
+.content { flex: 1; padding: 34px 40px; max-width: 1200px; }
+.lesson-head h2 { font-size: 26px; margin-bottom: 6px; }
+.lesson-head p { color: var(--muted); margin-bottom: 24px; }
+
+.lesson-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 340px;
+ gap: 24px;
+ align-items: start;
+}
+.canvas-wrap {
+ background: #0f1420;
+ border: 1px solid var(--line);
+ border-radius: 16px;
+ overflow: hidden;
+ aspect-ratio: 1 / 1;
+}
+.canvas-wrap canvas { width: 100%; height: 100%; display: block; cursor: grab; }
+.canvas-wrap canvas:active { cursor: grabbing; }
+
+/* 右侧讲解面板 */
+.panel {
+ background: var(--panel);
+ border: 1px solid var(--line);
+ border-radius: 16px;
+ padding: 22px;
+}
+.panel h3 { font-size: 17px; margin-bottom: 12px; }
+.panel p { color: var(--muted); font-size: 14px; line-height: 1.75; margin-bottom: 14px; }
+.panel strong { color: var(--text); }
+.panel code {
+ background: var(--panel-2); padding: 1px 6px; border-radius: 5px;
+ font-family: ui-monospace, monospace; font-size: 13px; color: var(--accent);
+}
+.tip { font-size: 13px !important; padding: 10px 12px; background: rgba(92,200,255,0.07);
+ border-left: 3px solid var(--accent); border-radius: 6px; }
+.formula {
+ text-align: center; font-family: ui-monospace, monospace;
+ background: var(--panel-2); padding: 12px; border-radius: 8px; color: var(--text) !important;
+}
+
+.readout { display: flex; flex-direction: column; gap: 8px; margin: 16px 0; }
+.readout > div {
+ display: flex; justify-content: space-between; align-items: center;
+ background: var(--panel-2); padding: 10px 14px; border-radius: 8px;
+}
+.readout span { color: var(--muted); font-size: 13px; }
+.readout b { font-family: ui-monospace, monospace; font-size: 14px; color: var(--warn); }
+
+.slider-row { margin: 14px 0; }
+.slider-row label { display: flex; justify-content: space-between; font-size: 14px; color: var(--muted); margin-bottom: 6px; }
+.slider-row b { color: var(--accent); font-family: ui-monospace, monospace; }
+input[type=range] { width: 100%; accent-color: var(--accent); }
+
+.check { display: flex; align-items: center; gap: 8px; font-size: 14px; color: var(--muted); cursor: pointer; }
+.check input { accent-color: var(--accent); width: 16px; height: 16px; }
+
+/* 矩阵输入 */
+.matrix-input { display: flex; flex-direction: column; gap: 8px; margin: 14px 0; align-items: center; }
+.mrow { display: flex; gap: 8px; }
+.matrix-input input {
+ width: 62px; padding: 9px; text-align: center;
+ background: var(--panel-2); border: 1px solid var(--line); border-radius: 8px;
+ color: var(--text); font-family: ui-monospace, monospace; font-size: 15px;
+}
+.matrix-input input:focus { outline: none; border-color: var(--accent); }
+
+.btn-row { display: flex; flex-wrap: wrap; gap: 6px; margin: 12px 0; }
+.btn-row button {
+ background: var(--panel-2); border: 1px solid var(--line); color: var(--muted);
+ padding: 6px 11px; border-radius: 7px; font-size: 12.5px; cursor: pointer;
+ transition: all .15s;
+}
+.btn-row button:hover { border-color: var(--accent); color: var(--text); }
+.play-btn {
+ width: 100%; margin: 6px 0 4px; padding: 11px;
+ background: linear-gradient(135deg, var(--accent), var(--accent-2));
+ color: #07101a; border: none; border-radius: 9px; font-size: 14px; font-weight: 600; cursor: pointer;
+}
+.play-btn:hover { filter: brightness(1.08); }
+
+@media (max-width: 900px) {
+ .app { flex-direction: column; }
+ .sidebar { width: 100%; height: auto; position: static; flex-direction: column; }
+ #nav { flex-direction: row; flex-wrap: wrap; }
+ .nav-item { flex: 1 1 140px; }
+ .content { padding: 24px 18px; }
+ .lesson-grid { grid-template-columns: 1fr; }
+}