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