基于 JSXGraph + Vue3.0 的交互式几何作图组件开发实践
组件概述
这是一个基于 Vue 3 和 JSXGraph 库开发的交互式几何作图组件。该组件提供了丰富的几何图形绘制功能,包括点、线、圆、多边形等基本图形,以及角度标记、直角标记等特殊标记。
核心功能
1. 基础绘图工具
- 点:支持在画板上任意位置创建点
- 线:支持绘制直线
- 多边形:支持绘制任意多边形,并可设置填充效果
- 向量:支持绘制向量
- 圆:支持通过圆心和半径或三点绘制圆
- 椭圆:支持绘制椭圆
2. 特殊标记功能
- 角度标记:可以标记任意角度的度数
- 直角标记:用于标记直角
- 等长标记:用于标记等长线段
- 角度弧:用于绘制角度弧
3. 显示控制
- 网格显示:可控制是否显示网格
- 坐标轴:可控制是否显示坐标轴
- 网格对齐:支持点自动对齐到网格
- 坐标显示:可显示点的坐标
- 标签显示:可控制是否显示图形标签
4. 样式定制
组件提供了丰富的样式定制选项:
- 点样式:可自定义点的颜色、大小和透明度
- 线样式:可自定义线的颜色、宽度和透明度
- 标签样式:可自定义标签的颜色、大小、透明度和偏移量
- 填充样式:可自定义多边形的填充颜色和透明度
5. 交互功能
- 撤销操作:支持撤销上一步操作
- 清空画板:可一键清空所有图形
- 元素选择:支持通过右键点击选择元素
- 批量样式应用:可将样式应用到选中的元素
- 导出功能:支持导出为 SVG 或 PNG 格式
技术实现
1. 核心依赖
- Vue 3:Vue + Typescript
- JSXGraph:用于实现几何图形的绘制和交互
- Tailwind CSS:用于构建现代化的 UI 界面
2. 依赖安装
bash
pnpm add jsxgraph
2. 完整代码实现
模板部分 (Template)
vue
<template>
<div class="w-full">
<!-- 错误提示 -->
<div v-if="errorMessage" class="mb-2 rounded-md bg-red-100 p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-red-700">{{ errorMessage }}</p>
</div>
</div>
</div>
<!-- 工具栏 -->
<div class="mb-2 flex flex-wrap items-center gap-2">
<!-- 绘图工具选择 -->
<div class="flex items-center gap-2">
<label class="font-medium">绘图工具:</label>
<select v-model="mode" class="rounded border px-2 py-1">
<optgroup label="基础工具">
<option value="point">点</option>
<option value="line">线</option>
<option value="polygon">面</option>
<option value="vector">向量</option>
</optgroup>
<optgroup label="圆与椭圆">
<option value="circle">圆(圆心和半径)</option>
<option value="circle3">三点圆</option>
<option value="ellipse">椭圆</option>
</optgroup>
</select>
</div>
<!-- 显示选项 -->
<div class="flex items-center gap-2">
<label class="font-medium">显示选项:</label>
<label class="inline-flex items-center">
<input type="checkbox" v-model="showGrid" @change="toggleGrid" class="form-checkbox" />
<span class="ml-2">网格</span>
</label>
<label class="inline-flex items-center">
<input type="checkbox" v-model="showAxis" @change="toggleAxis" class="form-checkbox" />
<span class="ml-2">坐标轴</span>
</label>
</div>
<!-- 操作按钮 -->
<div class="flex gap-2">
<button @click="undo" class="rounded bg-gray-500 px-3 py-1 text-white" :disabled="!canUndo">
撤销
</button>
<button @click="clearBoard" class="rounded bg-red-500 px-3 py-1 text-white">
清空画板
</button>
</div>
</div>
<!-- 样式控制面板 -->
<div class="mb-2 flex flex-wrap items-center gap-4 rounded-md border border-gray-200 p-3">
<!-- 点样式控制 -->
<div class="flex items-center gap-2">
<label class="font-medium">点样式:</label>
<input type="color" v-model="pointStyle.color" class="h-8 w-8" title="点颜色" />
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2">
<span class="text-sm text-gray-500">大小: {{ pointStyle.size }}</span>
<input type="range" v-model="pointStyle.size" min="0" max="20" class="w-24" />
</div>
</div>
</div>
</div>
<!-- 画板容器 -->
<div ref="boardRef" class="h-[600px] w-full rounded-md border"></div>
</div>
</template>
脚本部分 (Script)
typescript
<script setup lang="ts">
import { onMounted, onUnmounted, ref, reactive, watch } from 'vue';
import JXG from 'jsxgraph';
const boardRef = ref<HTMLDivElement | null>(null);
const mode = ref<string>('point');
const showGrid = ref(true);
const showAxis = ref(true);
const snapToGrid = ref(true);
const showCoordinates = ref(false);
const canUndo = ref(false);
const errorMessage = ref('');
const showLabels = ref(true);
interface BoardElement extends JXG.GeometryElement {
elType: string;
selected?: boolean;
visProp: {
[name: string]: unknown;
fillColor?: string;
};
}
type CustomPoint = JXG.Point & { selected?: boolean };
type CustomLine = JXG.Line & { selected?: boolean };
type CustomCircle = JXG.Circle & { selected?: boolean };
type CustomArrow = JXG.Arrow & { selected?: boolean };
let board: JXG.Board | null = null;
const state = reactive({
elements: [] as BoardElement[],
points: [] as JXG.Point[],
tempPoints: [] as JXG.Point[],
});
// 文件输入引用
const fileInput = ref<HTMLInputElement | null>(null);
// 添加样式控制状态
const pointStyle = reactive({
color: '#1e40af',
size: 4,
opacity: 1
});
const lineStyle = reactive({
color: '#2563eb',
width: 2,
opacity: 1
});
const hasSelectedElements = ref(false);
const labelStyle = reactive({
color: '#1e40af',
size: 14,
offset: [10, 10],
opacity: 1
});
const fillStyle = reactive({
enabled: true,
color: '#93c5fd',
opacity: 0.3
});
// 添加导出格式状态
const exportFormat = ref<'svg' | 'png'>('svg');
// 修改 loadMathJax 函数
const loadMathJax = () => {
return new Promise((resolve, reject) => {
if ((window as any).MathJax) {
resolve((window as any).MathJax);
return;
}
// 简化 MathJax 配置,只保留几何标签渲染所需功能
window.MathJax = {
tex: {
inlineMath: [['$', '$']],
processEscapes: true
},
svg: {
fontCache: 'global'
}
};
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js';
script.async = true;
script.onload = () => {
resolve((window as any).MathJax);
};
script.onerror = reject;
document.head.appendChild(script);
});
};
// 初始化画板
onMounted(async () => {
try {
// await loadMathJax(); //todo
if (!boardRef.value) return;
board = JXG.JSXGraph.initBoard(boardRef.value, {
boundingbox: [-15, 15, 15, -15],
axis: false, // 初始化时不显示坐标轴
grid: false, // 初始化时不显示网格
showNavigation: true,
showCopyright: false,
keepaspectratio: true,
pan: {
enabled: true
}
});
// 添加画板事件监听
board.on('down', handleBoardClick);
board.on('move', () => {
if (board) {
board.update();
}
});
// 初始化时根据选项显示网格和坐标轴
if (showGrid.value) {
board.create('grid', []);
}
if (showAxis.value) {
board.create('axis', [[0, 0], [1, 0]]);
board.create('axis', [[0, 0], [0, 1]]);
}
// 禁用右键菜单
boardRef.value.addEventListener('contextmenu', (e: Event) => {
e.preventDefault();
});
} catch (error) {
console.error('Failed to initialize geometry board:', error);
}
});
// 清理资源
onUnmounted(() => {
if (board) {
board.off('down');
JXG.JSXGraph.freeBoard(board);
board = null;
}
});
// 处理点击事件
const handleBoardClick = (e: MouseEvent) => {
if (!board) return;
// 阻止默认的右键菜单
e.preventDefault();
const coords = board.getUsrCoordsOfMouse(e);
const [x, y] = coords;
// 获取点击位置下的所有元素
const elementsUnderMouse = board.getAllObjectsUnderMouse(e);
// 右键点击用于选择元素
if (e.button === 2) {
const clickedElement = elementsUnderMouse[0] as BoardElement;
if (clickedElement) {
clickedElement.selected = !clickedElement.selected;
hasSelectedElements.value = board.objectsList.some((obj: any) => (obj as BoardElement).selected);
// 更新元素的显示样式
if (clickedElement.selected) {
if (clickedElement.elType === 'point') {
clickedElement.setAttribute({strokeColor: '#ff0000'});
} else {
clickedElement.setAttribute({
strokeColor: '#ff0000',
fillColor: clickedElement.elType === 'polygon' ? '#ff0000' : clickedElement.visProp.fillColor
});
}
} else {
if (clickedElement.elType === 'point') {
clickedElement.setAttribute({strokeColor: pointStyle.color});
} else {
clickedElement.setAttribute({
strokeColor: lineStyle.color,
fillColor: clickedElement.elType === 'polygon' ? fillStyle.color : clickedElement.visProp.fillColor
});
}
}
board.update();
}
return;
}
// 左键点击用于创建元素
if (e.button === 0) {
// 检查是否点击了已存在的点
const clickedPoint = elementsUnderMouse.find(
(obj: any) => obj.elType === 'point'
);
// 如果点击了已存在的点,不创建新点
if (clickedPoint) {
return;
}
switch (mode.value) {
case 'point':
createPoint(x, y);
break;
case 'line':
handleLineCreation(x, y);
break;
case 'ray':
handleRayCreation(x, y);
break;
case 'vector':
handleVectorCreation(x, y);
break;
case 'circle':
handleCircleCreation(x, y);
break;
case 'circle3':
handleCircle3Creation(x, y);
break;
case 'ellipse':
handleEllipseCreation(x, y);
break;
case 'polygon':
handlePolygonCreation(x, y);
break;
case 'midpoint':
handleMidpointCreation(x, y);
break;
case 'angle':
handleAngleMarkCreation(x, y);
break;
case 'rightangle':
handleRightAngleMarkCreation(x, y);
break;
case 'equal':
handleEqualMarkCreation(x, y);
break;
case 'arc':
handleArcCreation(x, y);
break;
}
}
};
// 创建点
function createPoint(x: number, y: number): JXG.Point {
const name = String.fromCharCode(65 + state.points.length);
const pt = board!.create('point', [x, y], {
name: name,
size: pointStyle.size,
withLabel: true,
label: {
offset: labelStyle.offset,
fontSize: labelStyle.size,
strokeColor: labelStyle.color,
opacity: labelStyle.opacity,
useMathJax: true,
parse: false,
fixed: false,
highlight: false,
visible: showLabels.value,
cssStyle: 'cursor: default'
},
snapToGrid: snapToGrid.value,
snapSizeX: 1,
snapSizeY: 1,
color: pointStyle.color,
opacity: pointStyle.opacity,
highlight: true,
drag: function() {
if (board) {
board.update();
}
}
}) as JXG.Point;
// 设置初始标签文本
if (showCoordinates.value && pt.label) {
pt.label.setText(`$${name}(${pt.X().toFixed(2)}, ${pt.Y().toFixed(2)})$`);
} else if (pt.label) {
pt.label.setText(`$${name}$`);
}
// 修改点的更新函数
pt.on('update', function(this: JXG.Point) {
if (showCoordinates.value && this.label) {
this.label.setText(`$${this.name}(${this.X().toFixed(2)}, ${this.Y().toFixed(2)})$`);
} else if (this.label) {
this.label.setText(`$${this.name}$`);
}
// 触发 MathJax 重新渲染
if (window.MathJax) {
window.MathJax.typeset?.([this.label?.rendNode]);
}
});
// 添加双击事件处理
pt.on('dblclick', function(this: JXG.Point) {
const input = document.createElement('input');
input.type = 'text';
input.value = this.name;
input.style.position = 'absolute';
input.style.zIndex = '1000';
input.style.width = '50px';
input.style.fontSize = '14px';
input.style.padding = '2px';
input.style.border = '1px solid #2563eb';
input.style.borderRadius = '4px';
input.style.backgroundColor = 'white';
input.style.textAlign = 'center';
// 获取点在屏幕上的坐标
const coords = this.coords.scrCoords;
const boardRect = boardRef.value?.getBoundingClientRect() || { left: 0, top: 0 };
// 设置输入框位置,直接使用点的屏幕坐标
input.style.left = `${coords[1] + boardRect.left - 25}px`;
input.style.top = `${coords[2] + boardRect.top - 10}px`;
document.body.appendChild(input);
input.focus();
input.select(); // 自动选中文本
const handleBlur = () => {
const newName = input.value.trim();
if (newName) {
this.name = newName;
if (showCoordinates.value && this.label) {
this.label.setText(`$${newName}(${this.X().toFixed(2)}, ${this.Y().toFixed(2)})$`);
} else if (this.label) {
this.label.setText(`$${newName}$`);
}
// 触发 MathJax 重新渲染
if (window.MathJax) {
window.MathJax.typesetPromise?.();
}
board!.update();
}
input.remove();
};
input.addEventListener('blur', handleBlur);
input.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
handleBlur();
}
});
});
state.points.push(pt);
state.elements.push(pt as BoardElement);
canUndo.value = true;
return pt;
}
// 修改标签文本更新后的处理
function updatePointsLabels() {
state.points.forEach((point) => {
if (showCoordinates.value && point.label) {
point.label.setText(`$${point.name}(${point.X().toFixed(2)}, ${point.Y().toFixed(2)})$`);
} else if (point.label) {
point.label.setText(`$${point.name}$`);
}
});
// 触发 MathJax 重新渲染
if (window.MathJax && board) {
const labelNodes = state.points
.filter(p => p.label && p.label.rendNode)
.map(p => p?.label?.rendNode);
window.MathJax.typeset?.(labelNodes);
}
if (board) {
board.update();
}
}
// 监听显示坐标选项的变化
watch(showCoordinates, (newValue) => {
updatePointsLabels();
if (board) {
board.update();
}
}, { immediate: true });
// 修改线段创建函数
function handleLineCreation(x: number, y: number) {
const pt = createPoint(x, y);
state.tempPoints.push(pt);
if (state.tempPoints.length === 2) {
const [p1, p2] = state.tempPoints;
const line = board!.create('line', [p1, p2], {
straightFirst: false,
straightLast: false,
strokeWidth: lineStyle.width,
strokeColor: lineStyle.color,
opacity: lineStyle.opacity,
highlight: true,
withLabel: true,
name: function() {
const dx = p2.X() - p1.X();
const dy = p2.Y() - p1.Y();
const length = Math.sqrt(dx * dx + dy * dy).toFixed(2);
// return `${length}`;
return '';
},
label: {
fontSize: 14,
strokeColor: lineStyle.color,
position: 'middle',
offset: [0, 10],
useMathJax: true,
parse: false
}
});
// 添加线段的选择事件
(line as unknown as JXG.GeometryElement).on('up', function(this: CustomLine) {
this.selected = !this.selected;
hasSelectedElements.value = board!.objectsList.some((obj: any) => obj.selected);
if (this.selected) {
this.setAttribute({strokeColor: '#ff0000'});
} else {
this.setAttribute({strokeColor: lineStyle.color});
}
board!.update();
});
state.elements.push(line as unknown as BoardElement);
state.tempPoints = [];
}
}
// 处理射线创建
function handleRayCreation(x: number, y: number) {
const pt = createPoint(x, y);
state.tempPoints.push(pt);
if (state.tempPoints.length === 2) {
const [p1, p2] = state.tempPoints;
const ray = board!.create('line', [p1, p2], {
straightFirst: false,
straightLast: true,
strokeWidth: 2,
strokeColor: '#2563eb',
});
state.elements.push(ray as unknown as BoardElement);
state.tempPoints = [];
}
}
// 修改向量创建函数
function handleVectorCreation(x: number, y: number) {
const pt = createPoint(x, y);
state.tempPoints.push(pt);
if (state.tempPoints.length === 2) {
const [p1, p2] = state.tempPoints;
const vector = board!.create('arrow', [p1, p2], {
strokeWidth: lineStyle.width,
strokeColor: lineStyle.color,
opacity: lineStyle.opacity,
withLabel: true,
name: function() {
const dx = p2.X() - p1.X();
const dy = p2.Y() - p1.Y();
const length = Math.sqrt(dx * dx + dy * dy).toFixed(2);
// return `|v| = ${length}`;
return '';
},
label: {
fontSize: 14,
strokeColor: lineStyle.color,
position: 'middle',
offset: [0, 10],
useMathJax: true,
parse: false
}
});
// 添加向量的选择事件
(vector as unknown as JXG.GeometryElement).on('up', function(this: CustomArrow) {
this.selected = !this.selected;
hasSelectedElements.value = board!.objectsList.some((obj: any) => obj.selected);
if (this.selected) {
this.setAttribute({strokeColor: '#ff0000'});
} else {
this.setAttribute({strokeColor: lineStyle.color});
}
board!.update();
});
state.elements.push(vector as unknown as BoardElement);
state.tempPoints = [];
}
}
// 修改圆的创建函数
function handleCircleCreation(x: number, y: number) {
const pt = createPoint(x, y);
state.tempPoints.push(pt);
if (state.tempPoints.length === 2) {
const [center, point] = state.tempPoints;
const circle = board!.create('circle', [center, point], {
strokeWidth: lineStyle.width,
strokeColor: lineStyle.color,
opacity: lineStyle.opacity,
fillColor: 'none',
withLabel: true,
name: function(this: JXG.Circle) {
const radius = this.Radius();
// return `r = ${radius.toFixed(2)}`;
return '';
},
label: {
fontSize: 14,
strokeColor: lineStyle.color,
position: 'top',
offset: [0, 10],
useMathJax: true,
parse: false
}
});
// 添加圆的选择事件
(circle as unknown as JXG.GeometryElement).on('up', function(this: CustomCircle) {
this.selected = !this.selected;
hasSelectedElements.value = board!.objectsList.some((obj: any) => obj.selected);
if (this.selected) {
this.setAttribute({strokeColor: '#ff0000'});
} else {
this.setAttribute({strokeColor: lineStyle.color});
}
board!.update();
});
state.elements.push(circle as unknown as BoardElement);
state.tempPoints = [];
}
}
// 处理三点圆创建
function handleCircle3Creation(x: number, y: number) {
const pt = createPoint(x, y);
state.tempPoints.push(pt);
if (state.tempPoints.length === 3) {
const circle = board!.create('circumcircle', [...state.tempPoints], {
strokeWidth: 2,
strokeColor: '#2563eb',
fillColor: 'none',
});
state.elements.push(circle as unknown as BoardElement);
state.tempPoints = [];
}
}
// 处理椭圆创建
function handleEllipseCreation(x: number, y: number) {
const pt = createPoint(x, y);
state.tempPoints.push(pt);
if (state.tempPoints.length === 3) {
const [f1, f2, p] = state.tempPoints;
const ellipse = board!.create('ellipse', [f1, f2, p], {
strokeWidth: 2,
strokeColor: '#2563eb',
fillColor: 'none',
}) as BoardElement;
state.elements.push(ellipse as unknown as BoardElement);
state.tempPoints = [];
}
}
// 修改多边形创建函数
function handlePolygonCreation(x: number, y: number) {
const pt = createPoint(x, y);
state.tempPoints.push(pt);
}
// 完成多边形创建
function completePolygon() {
if (!board || state.tempPoints.length < 3) return;
const polygon = board.create('polygon', [...state.tempPoints], {
borders: {
strokeWidth: lineStyle.width,
strokeColor: lineStyle.color,
opacity: lineStyle.opacity,
highlight: true
},
fillColor: fillStyle.enabled ? fillStyle.color : 'transparent',
fillOpacity: fillStyle.opacity,
withLabel: true,
name: function(this: JXG.Polygon) {
const area = this.Area();
// return `A = ${area.toFixed(2)}`;
return '';
},
label: {
fontSize: labelStyle.size,
strokeColor: labelStyle.color,
position: 'middle',
offset: labelStyle.offset,
useMathJax: true,
parse: false,
visible: showLabels.value
}
}) as BoardElement;
// 添加多边形的选择事件
(polygon as unknown as JXG.GeometryElement).on('up', function(this: any) {
this.selected = !this.selected;
hasSelectedElements.value = board!.objectsList.some((obj: any) => obj.selected);
if (this.selected) {
this.setAttribute({
strokeColor: '#ff0000',
fillColor: '#ff0000'
});
} else {
this.setAttribute({
strokeColor: lineStyle.color,
fillColor: lineStyle.color
});
}
board!.update();
});
state.elements.push(polygon as unknown as BoardElement);
state.tempPoints = [];
}
// 处理中点创建
function handleMidpointCreation(x: number, y: number) {
const pt = createPoint(x, y);
state.tempPoints.push(pt);
if (state.tempPoints.length === 2) {
const [p1, p2] = state.tempPoints;
const midpoint = board!.create('midpoint', [p1, p2], {
withLabel: true,
name: 'M',
color: '#dc2626',
});
state.elements.push(midpoint as unknown as BoardElement);
state.tempPoints = [];
}
}
// 切换网格显示
function toggleGrid() {
if (!board) return;
// 移除现有的网格
const grids = board.objectsList.filter((obj: any) => obj.elType === 'grid');
grids.forEach((grid) => board?.removeObject(grid as BoardElement));
// 如果需要显示网格,则创建新的网格
if (showGrid.value) {
board?.create('grid', [], {});
}
board?.update();
}
// 切换坐标轴显示
function toggleAxis() {
if (!board) return;
// 移除现有的坐标轴
const axes = board.objectsList.filter((obj: any) => obj.elType === 'axis');
axes.forEach((axis) => board?.removeObject(axis as BoardElement));
// 如果需要显示坐标轴,则创建新的坐标轴
if (showAxis.value) {
board?.create('axis', [[0, 0], [1, 0]] as [number, number][]);
board?.create('axis', [[0, 0], [0, 1]] as [number, number][]);
}
board?.update();
}
// 撤销操作
function undo() {
if (state.elements.length > 0) {
const element = state.elements.pop();
if (element && board) {
board.removeObject(element as BoardElement);
if (element.elType === 'point') {
state.points = state.points.filter((p) => p !== element);
}
}
canUndo.value = state.elements.length > 0;
}
}
// 清空画板
function clearBoard() {
if (!board) return;
const b = board;
state.elements.forEach((element) => {
b.removeObject(element as BoardElement);
});
state.elements = [];
state.points = [];
state.tempPoints = [];
canUndo.value = false;
}
// 修改导出图像函数
async function exportAsImage() {
try {
const svgEl = boardRef.value?.querySelector('svg');
if (!svgEl) {
console.error('未找到 SVG 元素');
return;
}
// 等待MathJax完成渲染
if (window.MathJax) {
await window.MathJax.typesetPromise();
await new Promise(resolve => setTimeout(resolve, 100));
}
// 克隆SVG元素并处理
const clonedSvg = svgEl.cloneNode(true) as SVGElement;
clonedSvg.style.backgroundColor = 'transparent';
// 移除网格和坐标轴
clonedSvg.querySelectorAll('[id^="JXG_GID"], [id*="grid"], [id*="axis"]').forEach(el => el.remove());
// 处理标签
const processLabels = () => {
const labels = clonedSvg.querySelectorAll('[id^="JXG_text"], .MathJax');
labels.forEach(label => {
const el = label as SVGElement;
el.style.visibility = 'visible';
el.style.display = 'block';
el.style.zIndex = '1000';
// 处理文本颜色
el.querySelectorAll('text').forEach(text => {
const color = text.getAttribute('stroke') || '#000000';
text.style.fill = color;
text.style.stroke = color;
});
});
};
// 处理标签和样式
processLabels();
// 计算边界
const bbox = svgEl.getBBox();
const padding = 40;
const width = bbox.width + padding;
const height = bbox.height + padding;
// 设置尺寸
clonedSvg.setAttribute('width', width.toString());
clonedSvg.setAttribute('height', height.toString());
clonedSvg.setAttribute('viewBox', `${bbox.x - padding/2} ${bbox.y - padding/2} ${width} ${height}`);
// 转换为字符串
const svgString = new XMLSerializer().serializeToString(clonedSvg);
if (exportFormat.value === 'svg') {
// 导出SVG
const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(blob);
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const a = document.createElement('a');
a.href = url;
a.download = `geometry-elements-${timestamp}.svg`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} else {
// 导出PNG
const canvas = document.createElement('canvas');
const scale = 2;
canvas.width = width * scale;
canvas.height = height * scale;
// 创建图像
const img = new Image();
const base64Data = btoa(unescape(encodeURIComponent(svgString)));
img.src = `data:image/svg+xml;base64,${base64Data}`;
// 等待图像加载
await new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = reject;
});
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.scale(scale, scale);
ctx.drawImage(img, 0, 0);
// 保存为PNG
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
canvas.toBlob((blob) => {
if (blob) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `geometry-elements-${timestamp}.png`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
}, 'image/png');
}
}
} catch (error) {
console.error('导出图像时发生错误:', error);
}
}
// 监听模式变化,清除临时点
watch(mode, () => {
if (state.tempPoints.length > 0) {
const shouldClear = window.confirm('切换工具将清除当前未完成的图形,是否继续?');
if (shouldClear) {
state.tempPoints.forEach((point) => {
if (board) board.removeObject(point as BoardElement);
});
state.tempPoints = [];
} else {
mode.value = 'polygon'; // 如果用户取消,保持在多边形模式
}
}
});
// 计算顺时针角度的函数
function calculateClockwiseAngle(p1: JXG.Point, p2: JXG.Point, p3: JXG.Point): number {
// 将点转换为相对于p2的向量
const v1x = p1.X() - p2.X();
const v1y = p1.Y() - p2.Y();
const v2x = p3.X() - p2.X();
const v2y = p3.Y() - p2.Y();
// 计算两个向量的角度
const angle1 = Math.atan2(v1y, v1x);
const angle2 = Math.atan2(v2y, v2x);
// 计算顺时针角度
let angle = angle2 - angle1;
// 确保角度为正值(0到360度之间)
if (angle < 0) {
angle += 2 * Math.PI;
}
return angle;
}
// 清除错误信息
function clearError() {
errorMessage.value = '';
}
// 显示错误信息
function showError(message: string) {
errorMessage.value = message;
// 3秒后自动清除错误信息
setTimeout(() => {
clearError();
}, 3000);
}
// 重新初始化画板
function reinitializeBoard() {
try {
if (board) {
board.off('down');
JXG.JSXGraph.freeBoard(board);
}
if (!boardRef.value) return;
board = JXG.JSXGraph.initBoard(boardRef.value, {
boundingbox: [-10, 10, 10, -10],
axis: showAxis.value,
grid: showGrid.value,
showNavigation: true,
showCopyright: false,
keepaspectratio: true,
pan: {
enabled: true
}
});
board.on('down', handleBoardClick);
board.on('move', () => {
if (board) {
board.update();
}
});
// 清空临时点
state.tempPoints = [];
} catch (error) {
console.error('重新初始化画板失败:', error);
showError('重新初始化画板失败,请刷新页面重试');
}
}
// 修改处理角度弧创建函数
function handleArcCreation(x: number, y: number) {
try {
const pt = createPoint(x, y);
state.tempPoints.push(pt);
if (state.tempPoints.length === 3) {
const [p1, p2, p3] = state.tempPoints;
// 创建角度弧
const arc = board!.create('angle', [p3, p2, p1], {
radius: 2,
type: 'sector',
strokeColor: '#2563eb',
fillColor: 'none',
name: function() {
const angleRad = calculateClockwiseAngle(p3, p2, p1);
const degrees = (angleRad * 180 / Math.PI).toFixed(1);
const radians = angleRad.toFixed(2);
return `${degrees}° (${radians} rad)`;
},
withLabel: true,
label: {
fontSize: 16,
strokeColor: '#2563eb',
cssStyle: 'font-style:italic',
position: 'top',
offset: [0, 0],
anchorX: 'middle',
anchorY: 'middle',
visible: true,
useMathJax: true,
parse: false
}
}) as BoardElement;
// 创建角度点之间的线段
const line1 = board!.create('segment', [p2, p3], {
strokeColor: '#2563eb',
strokeWidth: 2
}) as BoardElement;
const line2 = board!.create('segment', [p2, p1], {
strokeColor: '#2563eb',
strokeWidth: 2
}) as BoardElement;
state.elements.push(arc, line1, line2);
state.tempPoints = [];
}
} catch (error) {
console.error('创建角度弧时发生错误:', error);
showError('创建角度弧失败,正在重新初始化画板...');
reinitializeBoard();
}
}
// 修改角度标记创建函数
function handleAngleMarkCreation(x: number, y: number) {
try {
const pt = createPoint(x, y);
state.tempPoints.push(pt);
if (state.tempPoints.length === 3) {
const [p1, p2, p3] = state.tempPoints;
// 创建角度标记
const angle = board!.create('angle', [p3, p2, p1], {
type: 'sector',
radius: 1.5,
strokeColor: lineStyle.color,
strokeWidth: lineStyle.width,
strokeOpacity: lineStyle.opacity,
fillColor: fillStyle.enabled ? fillStyle.color : 'transparent',
fillOpacity: fillStyle.opacity,
name: function() {
const angleRad = calculateClockwiseAngle(p3, p2, p1);
const degrees = (angleRad * 180 / Math.PI).toFixed(1);
return `${degrees}°`;
},
label: {
fontSize: labelStyle.size,
strokeColor: labelStyle.color,
cssStyle: 'font-style:italic',
position: 'top',
offset: labelStyle.offset,
anchorX: 'middle',
anchorY: 'middle',
visible: showLabels.value,
useMathJax: true,
parse: false
}
}) as BoardElement;
// 创建角度点之间的线段
const line1 = board!.create('segment', [p2, p3], {
strokeColor: '#2563eb',
strokeWidth: 2
}) as BoardElement;
const line2 = board!.create('segment', [p2, p1], {
strokeColor: '#2563eb',
strokeWidth: 2
}) as BoardElement;
state.elements.push(angle, line1, line2);
state.tempPoints = [];
if (board) {
board.update();
}
}
} catch (error) {
console.error('创建角度标记时发生错误:', error);
showError('创建角度标记失败,正在重新初始化画板...');
reinitializeBoard();
}
}
// 触发文件选择
function triggerFileInput() {
fileInput.value?.click();
}
// 保存为JSON
function saveToJson() {
try {
const boardData = {
elements: state.elements.map(element => {
if (element.elType === 'point') {
return {
type: 'point',
name: element.name,
x: (element as JXG.Point).X(),
y: (element as JXG.Point).Y()
};
} else if (element.elType === 'line') {
const line = element as JXG.Line;
return {
type: 'line',
straightFirst: line.visProp.straightfirst,
straightLast: line.visProp.straightlast,
point1: {
x: line.point1.X(),
y: line.point1.Y()
},
point2: {
x: line.point2.X(),
y: line.point2.Y()
}
};
} else if (element.elType === 'circle') {
const circle = element as JXG.Circle;
const radius = circle.Radius();
return {
type: 'circle',
center: {
x: circle.center.X(),
y: circle.center.Y()
},
radius: radius,
point: {
x: circle.center.X() + radius,
y: circle.center.Y()
}
};
} else if (element.elType === 'polygon') {
const polygon = element as JXG.Polygon;
return {
type: 'polygon',
vertices: polygon.vertices.map(vertex => ({
x: vertex.X(),
y: vertex.Y()
}))
};
} else if (element.elType === 'angle') {
const angle = element as any;
return {
type: 'angle',
point1: {
x: angle.point1.X(),
y: angle.point1.Y()
},
point2: {
x: angle.point2.X(),
y: angle.point2.Y()
},
point3: {
x: angle.point3.X(),
y: angle.point3.Y()
},
angleType: angle.visProp.type,
radius: angle.visProp.radius
};
} else if (element.elType === 'arrow') {
const arrow = element as JXG.Arrow;
return {
type: 'vector',
point1: {
x: arrow.point1.X(),
y: arrow.point1.Y()
},
point2: {
x: arrow.point2.X(),
y: arrow.point2.Y()
}
};
}
return null;
}).filter(Boolean)
};
const jsonStr = JSON.stringify(boardData, null, 2);
const blob = new Blob([jsonStr], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const a = document.createElement('a');
a.href = url;
a.download = `geometry-board-${timestamp}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (error) {
console.error('保存数据时发生错误:', error);
}
}
// 从JSON加载
async function loadFromJson(event: Event) {
try {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) return;
const text = await file.text();
const data = JSON.parse(text);
// 清空当前画板
clearBoard();
// 重建元素
for (const element of data.elements) {
if (element.type === 'point') {
const pt = board!.create('point', [element.x, element.y], {
name: element.name,
size: 4,
withLabel: true,
label: {
offset: [10, 10],
fontSize: 14,
useMathJax: true,
parse: false,
fixed: false,
highlight: false,
visible: true,
cssStyle: 'cursor: default'
},
snapToGrid: snapToGrid.value,
snapSizeX: 1,
snapSizeY: 1,
color: '#1e40af'
}) as JXG.Point;
// 添加坐标显示的更新函数
pt.on('update', function(this: JXG.Point) {
if (showCoordinates.value && this.label) {
this.label.setText(`${this.name} (${this.X().toFixed(2)}, ${this.Y().toFixed(2)})`);
} else if (this.label) {
this.label.setText(this.name);
}
});
state.points.push(pt);
state.elements.push(pt as BoardElement);
} else if (element.type === 'line') {
const p1 = createPoint(element.point1.x, element.point1.y);
const p2 = createPoint(element.point2.x, element.point2.y);
const line = board!.create('line', [p1, p2], {
straightFirst: element.straightFirst,
straightLast: element.straightLast,
strokeWidth: 2,
strokeColor: '#2563eb'
});
state.elements.push(line as BoardElement);
} else if (element.type === 'circle') {
const center = createPoint(element.center.x, element.center.y);
const point = createPoint(element.point.x, element.point.y);
const circle = board!.create('circle', [center, point], {
strokeWidth: 2,
strokeColor: '#2563eb',
fillColor: 'none'
});
state.elements.push(circle as BoardElement);
} else if (element.type === 'polygon') {
const vertices = element.vertices.map((v: any) => createPoint(v.x, v.y));
const polygon = board!.create('polygon', vertices, {
borders: { strokeWidth: 2, strokeColor: '#2563eb' },
fillColor: '#93c5fd',
fillOpacity: 0.3
});
state.elements.push(polygon as BoardElement);
} else if (element.type === 'vector') {
const p1 = createPoint(element.point1.x, element.point1.y);
const p2 = createPoint(element.point2.x, element.point2.y);
const vector = board!.create('arrow', [p1, p2], {
strokeWidth: 2,
strokeColor: '#2563eb'
});
state.elements.push(vector as BoardElement);
} else if (element.type === 'angle') {
const p1 = createPoint(element.point1.x, element.point1.y);
const p2 = createPoint(element.point2.x, element.point2.y);
const p3 = createPoint(element.point3.x, element.point3.y);
// 创建角度标记
const angle = board!.create('angle', [p1, p2, p3], {
type: element.angleType || 'sector',
radius: element.radius || 0.8,
strokeColor: '#2563eb',
fillColor: element.angleType === 'square' ? 'none' : '#93c5fd',
fillOpacity: 0.3,
withLabel: true,
name: () => {
try {
const angleRad = calculateClockwiseAngle(p1, p2, p3);
return (angleRad * 180 / Math.PI).toFixed(1) + '°';
} catch (e) {
return '0°';
}
},
label: {
fontSize: 14,
strokeColor: '#2563eb',
cssStyle: 'font-style:italic',
position: 'top',
offset: [0, 0],
anchorX: 'middle',
anchorY: 'middle',
visible: true,
useMathJax: true,
parse: false
}
});
// 创建角度点之间的线段
const line1 = board!.create('segment', [p2, p1], {
strokeColor: '#2563eb',
strokeWidth: 2
});
const line2 = board!.create('segment', [p2, p3], {
strokeColor: '#2563eb',
strokeWidth: 2
});
state.elements.push(angle as BoardElement, line1 as BoardElement, line2 as BoardElement);
}
}
// 更新画板
board?.update();
// 清空文件输入,以便可以重复选择同一个文件
if (fileInput.value) {
fileInput.value.value = '';
}
} catch (error) {
console.error('加载数据时发生错误:', error);
}
}
// 处理直角标记创建
function handleRightAngleMarkCreation(x: number, y: number) {
try {
const pt = createPoint(x, y);
state.tempPoints.push(pt);
if (state.tempPoints.length === 3) {
const [p1, p2, p3] = state.tempPoints;
// 创建直角标记
const rightAngle = board!.create('angle', [p3, p2, p1], {
type: 'square', // 使用方形标记表示直角
radius: 0.8,
strokeColor: '#2563eb',
fillColor: 'none',
name: function() {
const angleRad = calculateClockwiseAngle(p3, p2, p1);
return (angleRad * 180 / Math.PI).toFixed(1) + '°';
},
withLabel: true,
label: {
fontSize: 14,
strokeColor: '#2563eb',
cssStyle: 'font-style:italic',
position: 'rd',
offset: [0, 0],
anchorX: 'middle',
anchorY: 'middle',
visible: true,
useMathJax: true,
parse: false,
fixed: false,
highlight: false,
rotate: true
}
});
// 创建角度点之间的线段
const line1 = board!.create('segment', [p2, p3], {
strokeColor: '#2563eb',
strokeWidth: 2
}) as BoardElement;
const line2 = board!.create('segment', [p2, p1], {
strokeColor: '#2563eb',
strokeWidth: 2
}) as BoardElement;
state.elements.push(rightAngle as BoardElement, line1, line2);
state.tempPoints = [];
}
} catch (error) {
console.error('创建直角标记时发生错误:', error);
showError('创建直角标记失败,正在重新初始化画板...');
reinitializeBoard();
}
}
// 处理等长标记创建
function handleEqualMarkCreation(x: number, y: number) {
try {
const pt = createPoint(x, y);
state.tempPoints.push(pt);
if (state.tempPoints.length === 4) {
const [p1, p2, p3, p4] = state.tempPoints;
// 创建第一条线段
const line1 = board!.create('segment', [p1, p2], {
strokeWidth: 2,
strokeColor: '#2563eb'
}) as BoardElement;
// 创建第二条线段
const line2 = board!.create('segment', [p3, p4], {
strokeWidth: 2,
strokeColor: '#2563eb'
}) as BoardElement;
// 在两条线段上添加相等标记(小线段)
const mark1 = board!.create('segment', [
board!.create('point', [() => {
const x = (p1.X() + p2.X()) / 2;
const y = (p1.Y() + p2.Y()) / 2;
return [x, y];
}], {visible: false}),
board!.create('point', [() => {
const x = (p1.X() + p2.X()) / 2;
const y = (p1.Y() + p2.Y()) / 2 + 0.3;
return [x, y];
}], {visible: false})
], {strokeWidth: 2, strokeColor: '#2563eb'}) as BoardElement;
const mark2 = board!.create('segment', [
board!.create('point', [() => {
const x = (p3.X() + p4.X()) / 2;
const y = (p3.Y() + p4.Y()) / 2;
return [x, y];
}], {visible: false}),
board!.create('point', [() => {
const x = (p3.X() + p4.X()) / 2;
const y = (p3.Y() + p4.Y()) / 2 + 0.3;
return [x, y];
}], {visible: false})
], {strokeWidth: 2, strokeColor: '#2563eb'}) as BoardElement;
state.elements.push(line1, line2, mark1, mark2);
state.tempPoints = [];
}
} catch (error) {
console.error('创建等长标记时发生错误:', error);
showError('创建等长标记失败,正在重新初始化画板...');
reinitializeBoard();
}
}
// 修改应用样式函数
function applyStylesToSelected() {
if (!board) return;
board.objectsList.forEach((obj: any) => {
if (obj.selected) {
if (obj.elType === 'point') {
obj.setAttribute({
color: pointStyle.color,
size: pointStyle.size,
opacity: pointStyle.opacity,
visible: showLabels.value,
label: {
color: labelStyle.color,
fontSize: labelStyle.size,
opacity: labelStyle.opacity,
offset: labelStyle.offset
}
});
} else if ([
'line', 'segment', 'arrow', 'circle', 'angle', 'polygon',
'circumcircle', 'ellipse'
].includes(obj.elType)) {
// 设置边框样式
obj.setAttribute({
strokeColor: lineStyle.color,
strokeWidth: lineStyle.width,
strokeOpacity: lineStyle.opacity
});
// 更新标签样式
if (obj.label) {
obj.label.setAttribute({
strokeColor: labelStyle.color,
fontSize: labelStyle.size,
opacity: labelStyle.opacity,
offset: labelStyle.offset,
visible: showLabels.value
});
}
// 如果是多边形或角度标记,更新填充样式
if (['polygon', 'angle'].includes(obj.elType)) {
obj.setAttribute({
fillColor: fillStyle.enabled ? fillStyle.color : 'transparent',
fillOpacity: fillStyle.opacity
});
}
}
obj.selected = false;
}
});
hasSelectedElements.value = false;
board.update();
}
// 修改标签显示选项
function toggleLabels() {
if (!board) return;
const b = board;
const labels = b.objectsList.filter((obj: any) => obj.elType === 'label');
labels.forEach((label: any) => {
label.visible = showLabels.value;
});
b.update();
}
样式部分 (Style)
scss
<style scoped>
@import url('https://cdn.jsdelivr.net/npm/jsxgraph/distrib/jsxgraph.css');
/* 表单控件样式 */
.form-checkbox {
@apply rounded border-gray-300 text-blue-600 shadow-sm focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50;
}
/* 滑块样式 */
input[type="range"] {
@apply h-2 rounded-lg bg-gray-200;
}
/* 颜色选择器样式 */
input[type="color"] {
@apply cursor-pointer rounded border border-gray-300;
}
/* 画板容器样式 */
.board-container {
@apply relative w-full h-[600px] border rounded-md overflow-hidden;
}
/* 工具栏样式 */
.toolbar {
@apply flex flex-wrap items-center gap-2 mb-2 p-2 bg-gray-50 rounded-md;
}
/* 样式控制面板 */
.style-panel {
@apply flex flex-wrap items-center gap-4 p-3 border rounded-md mb-2;
}
/* 按钮样式 */
.btn {
@apply px-3 py-1 rounded text-white transition-colors duration-200;
}
.btn-primary {
@apply bg-blue-500 hover:bg-blue-600;
}
.btn-danger {
@apply bg-red-500 hover:bg-red-600;
}
.btn-success {
@apply bg-green-500 hover:bg-green-600;
}
/* 禁用状态样式 */
.disabled {
@apply opacity-50 cursor-not-allowed;
}
/* 标签样式 */
.label {
@apply font-medium text-gray-700;
}
/* 工具提示样式 */
[title] {
@apply cursor-help;
}
</style>
效果展示

总结
这个几何作图组件通过结合 Vue 3 和 JSXGraph,实现了功能丰富、交互友好的几何图形绘制功能。它不仅提供了基础的绘图工具,还支持丰富的样式定制和交互操作,可以满足教学、演示等多种场景的需求。