1814 字
9 分钟
数学图像功能实现教程

功能预览#

最终效果:

  • ✅ 在 Markdown 中使用 mathgraph 代码块
  • ✅ 自动渲染交互式数学图形
  • ✅ 滑动条实时调整参数
  • ✅ 左图右参数布局
  • ✅ 自动适配 Fuwari 主题
  • ✅ 支持亮色/暗色模式
  • ✅ 移动端响应式设计
NOTE

所有代码都是AI敲的,所以相关问题不要问我

实现步骤#

步骤 1: 下载 JSXGraph 库到本地#

首先,我们需要将 JSXGraph 库下载到本地,避免远程调用可能遇到的网络问题。

Terminal window
# 创建目录
mkdir -p public/jsxgraph
# 下载 JSXGraph 核心库
curl -o public/jsxgraph/jsxgraphcore.js https://cdn.jsdelivr.net/npm/jsxgraph@1.8.0/distrib/jsxgraphcore.js
# 下载 JSXGraph 样式
curl -o public/jsxgraph/jsxgraph.css https://cdn.jsdelivr.net/npm/jsxgraph@1.8.0/distrib/jsxgraph.css

说明

  • public/ 目录下的文件会被直接复制到构建输出
  • 使用本地文件避免依赖远程 CDN
  • 确保部署到任何平台都能正常工作

步骤 2: 创建 Remark 插件#

创建 src/plugins/remark-mathgraph.js 文件,用于解析 Markdown 中的 mathgraph 代码块。

import { visit } from "unist-util-visit";
/**
* Remark plugin to detect and transform mathgraph code blocks.
* Converts ```mathgraph code blocks to interactive math graph components.
*/
export function remarkMathGraph() {
return (tree) => {
visit(tree, "code", (node) => {
if (node.lang === "mathgraph") {
const config = parseMathGraphConfig(node.value || "");
const configJson = JSON.stringify(config);
const graphId = `mathgraph-${Math.random().toString(36).substr(2, 9)}`;
// 创建完整的 HTML 结构
node.data = {
hName: "div",
hProperties: {
className: ["math-graph-container"],
},
hChildren: [
{
type: "element",
tagName: "div",
properties: {
className: ["math-graph-wrapper"],
},
children: [
{
type: "element",
tagName: "div",
properties: {
className: ["math-graph-canvas"],
id: graphId,
"data-config": configJson,
},
children: [],
},
],
},
],
};
}
});
};
}
function parseMathGraphConfig(content) {
const config = {
sliders: [],
expressions: [],
axis: true,
grid: true,
};
const lines = content.split("\n");
let currentSection = "";
for (const line of lines) {
const trimmed = line.trim();
// 跳过空行和注释
if (!trimmed || trimmed.startsWith("#")) continue;
// 检测区块
if (trimmed === "sliders:") {
currentSection = "sliders";
continue;
}
if (trimmed === "expressions:") {
currentSection = "expressions";
continue;
}
// 解析滑动条配置
if (currentSection === "sliders") {
const sliderMatch = trimmed.match(/^(\w+):\s*\[([^\]]+)\]$/);
if (sliderMatch) {
const name = sliderMatch[1];
const values = sliderMatch[2].split(",").map((v) => parseFloat(v.trim()));
if (values.length >= 3) {
config.sliders.push({
name,
min: values[0],
value: values[1],
max: values[2],
step: values[3] || 0.1,
});
}
}
}
// 解析表达式
if (currentSection === "expressions") {
const exprMatch = trimmed.match(/^-\s*(.+)$/);
if (exprMatch) {
config.expressions.push(exprMatch[1].trim());
}
}
// 解析其他配置
if (trimmed.startsWith("axis:")) {
config.axis = trimmed.split(":")[1].trim() === "true";
}
if (trimmed.startsWith("grid:")) {
config.grid = trimmed.split(":")[1].trim() === "true";
}
}
return config;
}

代码说明

  1. 使用 unist-util-visit 遍历 Markdown AST
  2. 检测 lang === "mathgraph" 的代码块
  3. 解析配置内容(滑动条、表达式等)
  4. 生成 HTML 结构,包含配置数据

步骤 3: 注册 Remark 插件#

astro.config.mjs 中注册插件:

import { remarkMathGraph } from "./src/plugins/remark-mathgraph.js";
export default defineConfig({
// ... 其他配置
markdown: {
remarkPlugins: [
// ... 其他插件
remarkMathGraph,
],
},
});

步骤 4: 添加样式#

创建 src/styles/mathgraph.css 文件:

/* MathGraph 样式 - 左图右参数布局 */
.math-graph-container {
margin: 1.5rem 0;
border-radius: 0.75rem;
overflow: hidden;
background: var(--card-bg);
border: 1px solid var(--line-divider);
}
/* 左图右参数布局 */
.math-graph-layout {
display: flex;
flex-direction: row;
gap: 0;
min-height: 400px;
}
.math-graph-canvas-wrapper {
flex: 1;
min-width: 0;
position: relative;
}
.math-graph-canvas {
width: 100%;
height: 400px;
background: var(--page-bg);
}
/* 参数控制面板 */
.math-graph-controls {
width: 280px;
min-width: 280px;
background: var(--btn-regular-bg);
border-left: 1px solid var(--line-divider);
display: flex;
flex-direction: column;
}
.math-graph-controls-title {
padding: 1rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--btn-content);
border-bottom: 1px solid var(--line-divider);
text-align: center;
}
/* 滑动条样式 */
.math-slider-wrapper {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
background: var(--card-bg);
border-radius: 0.5rem;
border: 1px solid var(--line-divider);
}
.math-slider-label {
font-size: 0.875rem;
font-weight: 600;
color: var(--btn-content);
display: flex;
justify-content: space-between;
}
.math-slider-value {
color: var(--primary);
}
.math-slider-input {
width: 100%;
height: 6px;
border-radius: 3px;
background: var(--btn-plain-bg-hover);
outline: none;
-webkit-appearance: none;
cursor: pointer;
}
.math-slider-input::-webkit-slider-thumb {
-webkit-appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--primary);
cursor: pointer;
}
/* 响应式设计 */
@media (max-width: 768px) {
.math-graph-layout {
flex-direction: column;
}
.math-graph-controls {
width: 100%;
border-left: none;
border-top: 1px solid var(--line-divider);
}
}

样式要点

  • 使用 Fuwari 的 CSS 变量(--card-bg, --primary 等)
  • 左图右参数布局(桌面端)
  • 移动端自动切换为上下布局

步骤 5: 在 Layout 中引入样式和脚本#

编辑 src/layouts/Layout.astro

---
// 在文件顶部引入样式
import "../styles/mathgraph.css";
---
<head>
<!-- 其他 head 内容 -->
<!-- JSXGraph 库 -->
<link rel="stylesheet" href="/jsxgraph/jsxgraph.css" />
<script src="/jsxgraph/jsxgraphcore.js" defer></script>
</head>

步骤 6: 添加初始化脚本#

src/layouts/Layout.astro<script> 标签中添加初始化代码:

// ========== 数学图形初始化 ==========
let mathGraphObserver = null;
function initMathGraphs() {
const containers = document.querySelectorAll(
'.math-graph-canvas[data-config]:not([data-initialized])'
);
if (containers.length === 0) return;
// 检查 JSXGraph 是否已加载
if (!window.JXG) {
waitForJSXGraph().then(initMathGraphs);
return;
}
containers.forEach((container) => {
const configStr = container.getAttribute('data-config');
if (!configStr) return;
try {
const config = JSON.parse(configStr);
createMathGraph(container, config);
container.setAttribute('data-initialized', 'true');
} catch (e) {
console.error('MathGraph config parse error:', e);
}
});
}
// 使用 MutationObserver 监听 DOM 变化
function observeMathGraphs() {
if (mathGraphObserver) {
mathGraphObserver.disconnect();
}
initMathGraphs();
mathGraphObserver = new MutationObserver((mutations) => {
let shouldInit = false;
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node instanceof Element) {
if (node.classList?.contains('math-graph-canvas') ||
node.querySelector?.('.math-graph-canvas')) {
shouldInit = true;
}
}
});
});
if (shouldInit) {
setTimeout(initMathGraphs, 100);
}
});
mathGraphObserver.observe(document.body, {
childList: true,
subtree: true,
});
}
// 等待 JSXGraph 加载
function waitForJSXGraph() {
return new Promise((resolve) => {
if (window.JXG) {
resolve();
} else {
const checkInterval = setInterval(() => {
if (window.JXG) {
clearInterval(checkInterval);
resolve();
}
}, 100);
setTimeout(() => {
clearInterval(checkInterval);
console.error('JSXGraph loading timeout');
}, 5000);
}
});
}
// 创建数学图形
async function createMathGraph(container, config) {
const boardId = container.id;
const JXG = window.JXG;
// 获取父容器
const wrapper = container.parentElement;
if (!wrapper) return;
// 创建布局容器
const layoutContainer = document.createElement('div');
layoutContainer.className = 'math-graph-layout';
const graphContainer = document.createElement('div');
graphContainer.className = 'math-graph-canvas-wrapper';
const controlsContainer = document.createElement('div');
controlsContainer.className = 'math-graph-controls';
const controlsTitle = document.createElement('div');
controlsTitle.className = 'math-graph-controls-title';
controlsTitle.textContent = '参数调整';
controlsContainer.appendChild(controlsTitle);
const slidersContainer = document.createElement('div');
slidersContainer.className = 'math-graph-sliders-vertical';
controlsContainer.appendChild(slidersContainer);
// 移动容器到新布局
wrapper.removeChild(container);
graphContainer.appendChild(container);
layoutContainer.appendChild(graphContainer);
layoutContainer.appendChild(controlsContainer);
wrapper.appendChild(layoutContainer);
// 创建 JSXGraph 画板
const board = JXG.JSXGraph.initBoard(boardId, {
boundingbox: [-10, 10, 10, -10],
axis: config.axis !== false,
grid: config.grid !== false,
showCopyright: false,
showNavigation: false,
keepAspectRatio: false,
});
const sliders = {};
// 创建滑动条
config.sliders.forEach((sliderConfig, index) => {
const sliderWrapper = document.createElement('div');
sliderWrapper.className = 'math-slider-wrapper';
const labelRow = document.createElement('div');
labelRow.className = 'math-slider-label';
const labelText = document.createElement('span');
labelText.textContent = sliderConfig.name;
const valueDisplay = document.createElement('span');
valueDisplay.className = 'math-slider-value';
valueDisplay.textContent = sliderConfig.value.toFixed(2);
labelRow.appendChild(labelText);
labelRow.appendChild(valueDisplay);
const slider = document.createElement('input');
slider.type = 'range';
slider.min = String(sliderConfig.min);
slider.max = String(sliderConfig.max);
slider.step = String(sliderConfig.step);
slider.value = String(sliderConfig.value);
slider.className = 'math-slider-input';
slider.addEventListener('input', () => {
const val = parseFloat(slider.value);
valueDisplay.textContent = val.toFixed(2);
sliders[sliderConfig.name].setValue(val);
board.update();
});
sliderWrapper.appendChild(labelRow);
sliderWrapper.appendChild(slider);
slidersContainer.appendChild(sliderWrapper);
// 创建 JSXGraph 内部滑动条
sliders[sliderConfig.name] = board.create('slider', [
[-8, -8 + index * 1.5],
[-3, -8 + index * 1.5],
[sliderConfig.min, sliderConfig.value, sliderConfig.max]
], {
name: sliderConfig.name,
visible: false,
});
});
// 创建函数曲线
config.expressions.forEach((expr, index) => {
try {
const func = new Function(
'x',
...Object.keys(sliders),
`with(Math) { return ${expr}; }`
);
board.create('functiongraph', [
(x) => {
const sliderValues = Object.keys(sliders).map(key => sliders[key].Value());
return func(x, ...sliderValues);
}
], {
strokeColor: getGraphColor(index),
strokeWidth: 2,
});
} catch (e) {
console.error('Expression parse error:', expr, e);
}
});
}
function getGraphColor(index) {
const colors = [
'#3b82f6',
'#e74c3c',
'#27ae60',
'#f39c12',
'#9b59b6',
'#1abc9c',
];
return colors[index % colors.length];
}
// 初始化
function initOnLoad() {
waitForJSXGraph().then(() => {
observeMathGraphs();
});
}
document.addEventListener('DOMContentLoaded', initOnLoad);
if (document.readyState !== 'loading') {
initOnLoad();
}
// 支持 Swup 页面切换
document.addEventListener('swup:contentReplaced', () => {
setTimeout(initMathGraphs, 300);
});
if (window?.swup?.hooks) {
window.swup.hooks.on('page:view', () => {
setTimeout(initMathGraphs, 300);
});
}

关键点说明

  1. MutationObserver: 监听 DOM 变化,自动初始化新添加的图形
  2. waitForJSXGraph: 等待 JSXGraph 库加载完成
  3. 防重复初始化: 使用 data-initialized 标记
  4. Swup 兼容: 监听页面切换事件

使用方法#

基本语法#

在 Markdown 文件中使用:

```mathgraph
sliders:
参数名: [最小值, 默认值, 最大值, 步长]
expressions:
- 数学表达式
axis: true/false
grid: true/false
```

支持的数学函数#

  • 基本运算: +, -, *, /, ^
  • 三角函数: sin, cos, tan, asin, acos, atan
  • 指数对数: exp, log, log10, sqrt
  • 其他: abs, floor, ceil, round

进阶定制#

自定义颜色#

修改 getGraphColor 函数:

function getGraphColor(index) {
const colors = [
'#your-color-1',
'#your-color-2',
// ...
];
return colors[index % colors.length];
}

调整画板范围#

修改 boundingbox 参数:

const board = JXG.JSXGraph.initBoard(boardId, {
boundingbox: [-5, 5, 5, -5], // [x_min, y_max, x_max, y_min]
// ...
});

添加更多配置选项#

parseMathGraphConfig 函数中添加新的配置项:

if (trimmed.startsWith("boundingbox:")) {
const values = trimmed.split(":")[1].trim().split(",").map(Number);
config.boundingbox = values;
}

常见问题#

Q: 图形不显示?#

A: 检查以下几点:

  1. JSXGraph 文件是否正确下载到 public/jsxgraph/
  2. 浏览器控制台是否有错误
  3. Markdown 语法是否正确

Q: 需要手动刷新才显示?#

A: 这是初始化时机问题,确保:

  1. 使用了 MutationObserver
  2. 等待 JSXGraph 加载完成
  3. 监听了 Swup 页面切换事件