Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .github/workflows/deploy-pages.yml
Original file line number Diff line number Diff line change
@@ -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
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)
- 矩阵乘法 = 变换的复合(动画串联)
- 每节课配练习题与即时判分
30 changes: 30 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>线性代数可视化 · 交互教学</title>
<meta name="description" content="用交互动画直观理解向量、线性组合、矩阵变换、行列式与特征向量。">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="app">
<aside class="sidebar">
<div class="brand">
<div class="logo">∑</div>
<div>
<h1>线性代数</h1>
<small>可视化交互课堂</small>
</div>
</div>
<nav id="nav"></nav>
<footer class="side-foot">
拖动圆点 · 调节滑块 · 播放动画<br>
在「玩」中建立几何直觉
</footer>
</aside>
<main id="main" class="content"></main>
</div>
<script type="module" src="js/app.js"></script>
</body>
</html>
41 changes: 41 additions & 0 deletions js/app.js
Original file line number Diff line number Diff line change
@@ -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 = `<span class="nav-num">${i + 1}</span>
<span><b>${l.meta.title}</b><small>${l.meta.subtitle}</small></span>`;
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 = `<h2>${lesson.meta.title}</h2><p>${lesson.meta.subtitle}</p>`;
main.appendChild(head);
const body = document.createElement('div');
main.appendChild(body);
lesson.mount(body);
}

window.addEventListener('hashchange', route);
route();
56 changes: 56 additions & 0 deletions js/lessons/addition.js
Original file line number Diff line number Diff line change
@@ -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 = `
<div class="lesson-grid">
<div class="canvas-wrap"><canvas id="cv"></canvas></div>
<aside class="panel">
<h3>把向量加起来</h3>
<p>两个向量相加,就是<strong>分量分别相加</strong>。几何上:把 w 的尾巴接到 v 的头上,
从原点指向 w 新终点的箭头就是 <code>v + w</code>。</p>
<p class="tip">拖动蓝、绿两个圆点。虚线展示了「首尾相接」。</p>
<div class="readout">
<div><span>v</span><b id="r-v">(2, 1)</b></div>
<div><span>w</span><b id="r-w">(1, 2)</b></div>
<div><span>v + w</span><b id="r-sum">(3, 3)</b></div>
</div>
<label class="check"><input type="checkbox" id="para"> 显示平行四边形</label>
</aside>
</div>`;

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();
}
71 changes: 71 additions & 0 deletions js/lessons/combination.js
Original file line number Diff line number Diff line change
@@ -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 = `
<div class="lesson-grid">
<div class="canvas-wrap"><canvas id="cv"></canvas></div>
<aside class="panel">
<h3>a·v + b·w</h3>
<p>把两个向量各自<strong>伸缩</strong>再相加,得到的就是它们的<strong>线性组合</strong>。
改变系数能扫过的全部位置,叫做这两个向量的<strong>张成空间(span)</strong>。</p>
<div class="slider-row"><label>a = <b id="va">1.0</b></label>
<input type="range" id="a" min="-3" max="3" step="0.1" value="1"></div>
<div class="slider-row"><label>b = <b id="vb">1.0</b></label>
<input type="range" id="b" min="-3" max="3" step="0.1" value="1"></div>
<p class="tip">拖动 v、w 两个圆点改变基向量。当它们<strong>共线</strong>时,张成空间会塌缩成一条直线。</p>
<div class="readout">
<div><span>结果点</span><b id="r-pt">(3, 3)</b></div>
<div><span>张成空间</span><b id="r-span">整个平面</b></div>
</div>
</aside>
</div>`;

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();
}
90 changes: 90 additions & 0 deletions js/lessons/eigen.js
Original file line number Diff line number Diff line change
@@ -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 = `
<div class="lesson-grid">
<div class="canvas-wrap"><canvas id="cv"></canvas></div>
<aside class="panel">
<h3>方向不变的向量</h3>
<p>变换会让大多数向量同时<strong>改变方向和长度</strong>。但某些特殊方向上的向量
只会被<strong>拉伸或压缩</strong>,方向保持在原直线上——它们就是<strong>特征向量</strong>,
拉伸倍数就是对应的<strong>特征值 λ</strong>。</p>
<div class="matrix-input">
<div class="mrow"><input id="m00" value="2"><input id="m01" value="1"></div>
<div class="mrow"><input id="m10" value="1"><input id="m11" value="2"></div>
</div>
<div class="btn-row">
<button data-preset="2,1,1,2">对称</button>
<button data-preset="2,0,0,3">对角</button>
<button data-preset="1,1,0,1">剪切</button>
<button data-preset="0,-1,1,0">旋转(无实特征)</button>
</div>
<p class="tip">拖动白点旋转测试向量。当蓝箭头与黄色虚线<strong>重合</strong>时,
变换后(橙色)与原向量共线——你找到特征方向了。</p>
<div class="readout" id="eig-out"></div>
</aside>
</div>`;

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 = '<div><span>实特征值</span><b>无(纯旋转)</b></div>';
} else {
out.innerHTML = eigs.map((e, i) =>
`<div><span>λ${i + 1}</span><b>${e.value.toFixed(2)} ,方向 (${e.vec[0].toFixed(2)}, ${e.vec[1].toFixed(2)})</b></div>`
).join('') + `<div><span>det</span><b>${det(M).toFixed(2)}</b></div>`;
}
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();
}
Loading
Loading