|
@@ -53,6 +53,10 @@ class SymjaConsole {
|
|
|
// MCP 状态更新
|
|
// MCP 状态更新
|
|
|
this.updateMcpStatus();
|
|
this.updateMcpStatus();
|
|
|
setInterval(() => this.updateMcpStatus(), 3000);
|
|
setInterval(() => this.updateMcpStatus(), 3000);
|
|
|
|
|
+
|
|
|
|
|
+ // MCP 事件同步(每秒轮询)
|
|
|
|
|
+ this.mcpEventCount = 0;
|
|
|
|
|
+ setInterval(() => this.pollMcpEvents(), 1000);
|
|
|
|
|
|
|
|
// 快捷键工具栏
|
|
// 快捷键工具栏
|
|
|
this.initShortcutsBar();
|
|
this.initShortcutsBar();
|
|
@@ -421,14 +425,13 @@ class SymjaConsole {
|
|
|
|
|
|
|
|
evaluate() {
|
|
evaluate() {
|
|
|
let expression = this.input.value.trim();
|
|
let expression = this.input.value.trim();
|
|
|
-
|
|
|
|
|
|
|
+
|
|
|
if (!expression) {
|
|
if (!expression) {
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// 处理特殊命令
|
|
// 处理特殊命令
|
|
|
if (this.handleSpecialCommand(expression)) {
|
|
if (this.handleSpecialCommand(expression)) {
|
|
|
- // 特殊命令也要添加到历史记录
|
|
|
|
|
this.addToHistory(expression);
|
|
this.addToHistory(expression);
|
|
|
this.input.value = '';
|
|
this.input.value = '';
|
|
|
return;
|
|
return;
|
|
@@ -442,17 +445,23 @@ class SymjaConsole {
|
|
|
|
|
|
|
|
// 调用Android接口进行计算
|
|
// 调用Android接口进行计算
|
|
|
try {
|
|
try {
|
|
|
- const result = Android.evaluate(expression);
|
|
|
|
|
-
|
|
|
|
|
- // 检查是否是特殊功能请求
|
|
|
|
|
- if (result.startsWith('##TEXFORM##')) {
|
|
|
|
|
|
|
+ const result = Android.evaluatePlot(expression);
|
|
|
|
|
+
|
|
|
|
|
+ // 检测是否是 Graphics/绘图结果
|
|
|
|
|
+ if (typeof result === 'string' && result.startsWith('{"type":"plot"')) {
|
|
|
|
|
+ this.addPlotOutput(result, this.executionCount);
|
|
|
|
|
+ }
|
|
|
|
|
+ // LaTeX 渲染
|
|
|
|
|
+ else if (result.startsWith('##TEXFORM##')) {
|
|
|
const latex = result.substring(11);
|
|
const latex = result.substring(11);
|
|
|
this.addOutput(latex, 'result', { renderLatex: true, count: this.executionCount });
|
|
this.addOutput(latex, 'result', { renderLatex: true, count: this.executionCount });
|
|
|
this.scrollToBottom();
|
|
this.scrollToBottom();
|
|
|
- } else {
|
|
|
|
|
|
|
+ }
|
|
|
|
|
+ // 普通文本结果
|
|
|
|
|
+ else {
|
|
|
this.addOutput(result, 'result', { count: this.executionCount });
|
|
this.addOutput(result, 'result', { count: this.executionCount });
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
|
|
+
|
|
|
// 追踪变量
|
|
// 追踪变量
|
|
|
this.trackVariables(expression);
|
|
this.trackVariables(expression);
|
|
|
} catch (e) {
|
|
} catch (e) {
|
|
@@ -468,11 +477,59 @@ class SymjaConsole {
|
|
|
this.input.value = '';
|
|
this.input.value = '';
|
|
|
this.completions = [];
|
|
this.completions = [];
|
|
|
this.completionIndex = -1;
|
|
this.completionIndex = -1;
|
|
|
-
|
|
|
|
|
|
|
+
|
|
|
// 滚动到底部
|
|
// 滚动到底部
|
|
|
this.scrollToBottom();
|
|
this.scrollToBottom();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ addPlotOutput(plotJson, count) {
|
|
|
|
|
+ const data = JSON.parse(plotJson);
|
|
|
|
|
+ const line = document.createElement('div');
|
|
|
|
|
+ line.className = 'terminal-line';
|
|
|
|
|
+
|
|
|
|
|
+ const container = document.createElement('div');
|
|
|
|
|
+ container.className = 'plot-container';
|
|
|
|
|
+
|
|
|
|
|
+ const canvas = document.createElement('canvas');
|
|
|
|
|
+ canvas.className = 'plot-canvas';
|
|
|
|
|
+ canvas.width = 720;
|
|
|
|
|
+ canvas.height = 500;
|
|
|
|
|
+ container.appendChild(canvas);
|
|
|
|
|
+
|
|
|
|
|
+ // 保存按钮
|
|
|
|
|
+ const actions = document.createElement('div');
|
|
|
|
|
+ actions.className = 'plot-actions';
|
|
|
|
|
+ const saveBtn = document.createElement('button');
|
|
|
|
|
+ saveBtn.className = 'plot-save-btn';
|
|
|
|
|
+ saveBtn.textContent = '💾 保存图片';
|
|
|
|
|
+ saveBtn.onclick = () => this.savePlot(canvas);
|
|
|
|
|
+ actions.appendChild(saveBtn);
|
|
|
|
|
+ container.appendChild(actions);
|
|
|
|
|
+
|
|
|
|
|
+ line.appendChild(container);
|
|
|
|
|
+ this.output.appendChild(line);
|
|
|
|
|
+
|
|
|
|
|
+ // 初始化渲染器
|
|
|
|
|
+ if (!this.plotRenderer) {
|
|
|
|
|
+ this.plotRenderer = new PlotRenderer();
|
|
|
|
|
+ }
|
|
|
|
|
+ this.plotRenderer.render(canvas, data);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ savePlot(canvas) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const dataUrl = canvas.toDataURL('image/png');
|
|
|
|
|
+ const base64 = dataUrl.substring(dataUrl.indexOf(',') + 1);
|
|
|
|
|
+ const filename = 'IJS_Plot_' + new Date().toISOString().replace(/[:.]/g, '-') + '.png';
|
|
|
|
|
+ const result = Android.savePlotImage(base64, filename);
|
|
|
|
|
+ this.addOutput(result, 'help');
|
|
|
|
|
+ this.scrollToBottom();
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ this.addOutput('保存失败: ' + e.message, 'error');
|
|
|
|
|
+ this.scrollToBottom();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
handleSpecialCommand(cmd) {
|
|
handleSpecialCommand(cmd) {
|
|
|
const lower = cmd.toLowerCase();
|
|
const lower = cmd.toLowerCase();
|
|
|
|
|
|
|
@@ -917,6 +974,26 @@ class SymjaConsole {
|
|
|
}
|
|
}
|
|
|
},
|
|
},
|
|
|
|
|
|
|
|
|
|
+ // 绘图
|
|
|
|
|
+ plot: function(func, variable, xMin, xMax) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const r = Android.evaluatePlot('Plot[' + func + ', {' + variable + ', ' + xMin + ', ' + xMax + '}]');
|
|
|
|
|
+ return r;
|
|
|
|
|
+ } catch (e) { return 'Error: ' + e.message; }
|
|
|
|
|
+ },
|
|
|
|
|
+ parametricPlot: function(xf, yf, t, tMin, tMax) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const r = Android.evaluatePlot('ParametricPlot[{' + xf + ', ' + yf + '}, {' + t + ', ' + tMin + ', ' + tMax + '}]');
|
|
|
|
|
+ return r;
|
|
|
|
|
+ } catch (e) { return 'Error: ' + e.message; }
|
|
|
|
|
+ },
|
|
|
|
|
+ listPlot: function(points) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const r = Android.evaluatePlot('ListPlot[' + points + ']');
|
|
|
|
|
+ return r;
|
|
|
|
|
+ } catch (e) { return 'Error: ' + e.message; }
|
|
|
|
|
+ },
|
|
|
|
|
+
|
|
|
// LaTeX 转换和渲染
|
|
// LaTeX 转换和渲染
|
|
|
toTeX: function(expr) {
|
|
toTeX: function(expr) {
|
|
|
try {
|
|
try {
|
|
@@ -1123,6 +1200,18 @@ class SymjaConsole {
|
|
|
} else if (type === 'plugin') {
|
|
} else if (type === 'plugin') {
|
|
|
// 插件的返回值(可运算结果)
|
|
// 插件的返回值(可运算结果)
|
|
|
line.innerHTML = `<div class="plugin-line">${this.escapeHtml(text)}</div>`;
|
|
line.innerHTML = `<div class="plugin-line">${this.escapeHtml(text)}</div>`;
|
|
|
|
|
+ } else if (type === 'mcp-input') {
|
|
|
|
|
+ const seq = options.seq || '';
|
|
|
|
|
+ line.innerHTML = `<div class="mcp-input-line">
|
|
|
|
|
+ <span class="mcp-badge">🌐 MCP</span>
|
|
|
|
|
+ <span class="mcp-prompt">[${seq}]:</span>
|
|
|
|
|
+ <span class="mcp-command-text">${this.escapeHtml(text)}</span>
|
|
|
|
|
+ </div>`;
|
|
|
|
|
+ } else if (type === 'mcp-result') {
|
|
|
|
|
+ line.innerHTML = `<div class="mcp-result-line">
|
|
|
|
|
+ <span class="mcp-prompt"></span>
|
|
|
|
|
+ <span class="mcp-result-text">${this.escapeHtml(text)}</span>
|
|
|
|
|
+ </div>`;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
this.output.appendChild(line);
|
|
this.output.appendChild(line);
|
|
@@ -1780,6 +1869,31 @@ class SymjaConsole {
|
|
|
status.className = 'mcp-status';
|
|
status.className = 'mcp-status';
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ pollMcpEvents() {
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (typeof Android === 'undefined' || !Android.getMcpEvents) return;
|
|
|
|
|
+ const json = Android.getMcpEvents();
|
|
|
|
|
+ if (!json || json === '[]') return;
|
|
|
|
|
+ const events = JSON.parse(json);
|
|
|
|
|
+ events.forEach(ev => {
|
|
|
|
|
+ this.mcpEventCount++;
|
|
|
|
|
+ this.addOutput(ev.expression, 'mcp-input', { seq: this.mcpEventCount });
|
|
|
|
|
+ // 检测绘图结果 → Canvas 渲染
|
|
|
|
|
+ if (ev.result && ev.result.startsWith('{"type":"plot"')) {
|
|
|
|
|
+ this.addPlotOutput(ev.result, this.mcpEventCount);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ this.addOutput(ev.result, 'mcp-result', { seq: this.mcpEventCount });
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ this.executionCount++;
|
|
|
|
|
+ this.saveExecutionCount();
|
|
|
|
|
+ this.updatePrompt();
|
|
|
|
|
+ this.scrollToBottom();
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ // Ignore polling errors
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// 初始化控制台
|
|
// 初始化控制台
|
|
@@ -1787,3 +1901,285 @@ let symjaConsole;
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
|
symjaConsole = new SymjaConsole();
|
|
symjaConsole = new SymjaConsole();
|
|
|
});
|
|
});
|
|
|
|
|
+
|
|
|
|
|
+// ── Canvas 绘图渲染器 ────────────────────────────────────────
|
|
|
|
|
+class PlotRenderer {
|
|
|
|
|
+ constructor() {
|
|
|
|
|
+ this.canvas = null;
|
|
|
|
|
+ this.ctx = null;
|
|
|
|
|
+ this.data = null;
|
|
|
|
|
+ this.transform = { ox: 0, oy: 0, sx: 1, sy: 1 };
|
|
|
|
|
+ this.bounds = { xMin: 0, xMax: 1, yMin: 0, yMax: 1 };
|
|
|
|
|
+ this.dpr = window.devicePixelRatio || 1;
|
|
|
|
|
+ this.colors = [
|
|
|
|
|
+ [0.369, 0.507, 0.710], [0.851, 0.208, 0.125],
|
|
|
|
|
+ [0.267, 0.612, 0.329], [0.737, 0.349, 0.800],
|
|
|
|
|
+ [1.0, 0.608, 0.0]
|
|
|
|
|
+ ];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ render(canvas, data) {
|
|
|
|
|
+ this.canvas = canvas;
|
|
|
|
|
+ this.ctx = canvas.getContext('2d');
|
|
|
|
|
+ this.data = data;
|
|
|
|
|
+
|
|
|
|
|
+ // 计算全局范围
|
|
|
|
|
+ this.bounds = this.computeBounds(data);
|
|
|
|
|
+ const padX = (this.bounds.xMax - this.bounds.xMin) * 0.05 || 0.5;
|
|
|
|
|
+ const padY = (this.bounds.yMax - this.bounds.yMin) * 0.05 || 0.5;
|
|
|
|
|
+ this.bounds.xMin -= padX;
|
|
|
|
|
+ this.bounds.xMax += padX;
|
|
|
|
|
+ this.bounds.yMin -= padY;
|
|
|
|
|
+ this.bounds.yMax += padY;
|
|
|
|
|
+
|
|
|
|
|
+ this.transform = { ox: 0, oy: 0, sx: 1, sy: 1 };
|
|
|
|
|
+ this.draw();
|
|
|
|
|
+
|
|
|
|
|
+ // 手势绑定
|
|
|
|
|
+ this.bindGestures();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ computeBounds(data) {
|
|
|
|
|
+ let xMin = Infinity, xMax = -Infinity, yMin = Infinity, yMax = -Infinity;
|
|
|
|
|
+ if (!data.elements) return { xMin: -1, xMax: 1, yMin: -1, yMax: 1 };
|
|
|
|
|
+ for (const el of data.elements) {
|
|
|
|
|
+ if (!el.points) continue;
|
|
|
|
|
+ for (const pt of el.points) {
|
|
|
|
|
+ xMin = Math.min(xMin, pt[0]);
|
|
|
|
|
+ xMax = Math.max(xMax, pt[0]);
|
|
|
|
|
+ yMin = Math.min(yMin, pt[1]);
|
|
|
|
|
+ yMax = Math.max(yMax, pt[1]);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!isFinite(xMin)) return { xMin: -1, xMax: 1, yMin: -1, yMax: 1 };
|
|
|
|
|
+ return { xMin, xMax, yMin, yMax };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ── 坐标变换 ──────────────────────────────────────
|
|
|
|
|
+ toCanvas(px, py) {
|
|
|
|
|
+ const w = this.canvas.width, h = this.canvas.height;
|
|
|
|
|
+ const m = 60; // 边距
|
|
|
|
|
+ const cw = w - 2 * m, ch = h - 2 * m;
|
|
|
|
|
+ const t = this.transform;
|
|
|
|
|
+ const b = this.bounds;
|
|
|
|
|
+ const xRange = b.xMax - b.xMin, yRange = b.yMax - b.yMin;
|
|
|
|
|
+ return {
|
|
|
|
|
+ x: m + (px - b.xMin - t.ox * xRange) / (xRange * t.sx) * cw,
|
|
|
|
|
+ y: h - m - (py - b.yMin - t.oy * yRange) / (yRange * t.sy) * ch
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ── 主绘制 ────────────────────────────────────────
|
|
|
|
|
+ draw() {
|
|
|
|
|
+ const ctx = this.ctx;
|
|
|
|
|
+ const w = this.canvas.width, h = this.canvas.height;
|
|
|
|
|
+ const m = 60;
|
|
|
|
|
+ const cw = w - 2 * m, ch = h - 2 * m;
|
|
|
|
|
+ const t = this.transform;
|
|
|
|
|
+ const b = this.bounds;
|
|
|
|
|
+
|
|
|
|
|
+ // 背景
|
|
|
|
|
+ ctx.fillStyle = '#FFFFFF';
|
|
|
|
|
+ ctx.fillRect(0, 0, w, h);
|
|
|
|
|
+
|
|
|
|
|
+ // 网格
|
|
|
|
|
+ this.drawGrid(m, cw, ch);
|
|
|
|
|
+
|
|
|
|
|
+ // 坐标轴
|
|
|
|
|
+ this.drawAxes(m, cw, ch);
|
|
|
|
|
+
|
|
|
|
|
+ // 数据曲线
|
|
|
|
|
+ if (this.data.elements) {
|
|
|
|
|
+ const useColors = this.colors;
|
|
|
|
|
+ this.data.elements.forEach((el, idx) => {
|
|
|
|
|
+ if (!el.points || el.points.length === 0) return;
|
|
|
|
|
+ const color = el.color || useColors[idx % useColors.length];
|
|
|
|
|
+ const rgb = `rgb(${Math.round(color[0]*255)},${Math.round(color[1]*255)},${Math.round(color[2]*255)})`;
|
|
|
|
|
+
|
|
|
|
|
+ if (el.type === 'line' || (el.type === 'point' && this.data.options && this.data.options.joined)) {
|
|
|
|
|
+ ctx.strokeStyle = rgb;
|
|
|
|
|
+ ctx.lineWidth = 2;
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ for (let i = 0; i < el.points.length; i++) {
|
|
|
|
|
+ const pt = this.toCanvas(el.points[i][0], el.points[i][1]);
|
|
|
|
|
+ if (i === 0) ctx.moveTo(pt.x, pt.y);
|
|
|
|
|
+ else ctx.lineTo(pt.x, pt.y);
|
|
|
|
|
+ }
|
|
|
|
|
+ ctx.stroke();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (el.type === 'point') {
|
|
|
|
|
+ ctx.fillStyle = rgb;
|
|
|
|
|
+ for (const pt of el.points) {
|
|
|
|
|
+ const p = this.toCanvas(pt[0], pt[1]);
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ ctx.arc(p.x, p.y, 3.5, 0, Math.PI * 2);
|
|
|
|
|
+ ctx.fill();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ drawGrid(m, cw, ch) {
|
|
|
|
|
+ const ctx = this.ctx;
|
|
|
|
|
+ const b = this.bounds;
|
|
|
|
|
+ const t = this.transform;
|
|
|
|
|
+ const xRange = b.xMax - b.xMin, yRange = b.yMax - b.yMin;
|
|
|
|
|
+
|
|
|
|
|
+ ctx.strokeStyle = '#E8EDF2';
|
|
|
|
|
+ ctx.lineWidth = 0.5;
|
|
|
|
|
+ ctx.fillStyle = '#8B95A5';
|
|
|
|
|
+ ctx.font = '11px -apple-system, sans-serif';
|
|
|
|
|
+ ctx.textAlign = 'center';
|
|
|
|
|
+
|
|
|
|
|
+ const xStep = this.niceStep(xRange, 5);
|
|
|
|
|
+ const yStep = this.niceStep(yRange, 5);
|
|
|
|
|
+
|
|
|
|
|
+ // X 网格 + 标签
|
|
|
|
|
+ let xStart = Math.floor((b.xMin + t.ox * xRange) / xStep) * xStep;
|
|
|
|
|
+ for (let x = xStart; x <= b.xMin + t.ox * xRange + xRange / t.sx + xStep; x += xStep) {
|
|
|
|
|
+ const pt = this.toCanvas(x, 0);
|
|
|
|
|
+ if (pt.x >= m && pt.x <= m + cw) {
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ ctx.moveTo(pt.x, m);
|
|
|
|
|
+ ctx.lineTo(pt.x, m + ch);
|
|
|
|
|
+ ctx.stroke();
|
|
|
|
|
+ ctx.fillText(this.fmtNum(x), pt.x, m + ch + 16);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Y 网格 + 标签
|
|
|
|
|
+ ctx.textAlign = 'right';
|
|
|
|
|
+ let yStart = Math.floor((b.yMin + t.oy * yRange) / yStep) * yStep;
|
|
|
|
|
+ for (let y = yStart; y <= b.yMin + t.oy * yRange + yRange / t.sy + yStep; y += yStep) {
|
|
|
|
|
+ const pt = this.toCanvas(0, y);
|
|
|
|
|
+ if (pt.y >= m && pt.y <= m + ch) {
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ ctx.moveTo(m, pt.y);
|
|
|
|
|
+ ctx.lineTo(m + cw, pt.y);
|
|
|
|
|
+ ctx.stroke();
|
|
|
|
|
+ ctx.fillText(this.fmtNum(y), m - 8, pt.y + 4);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ drawAxes(m, cw, ch) {
|
|
|
|
|
+ const ctx = this.ctx;
|
|
|
|
|
+ const b = this.bounds;
|
|
|
|
|
+ const t = this.transform;
|
|
|
|
|
+ const origin = this.toCanvas(0, 0);
|
|
|
|
|
+
|
|
|
|
|
+ ctx.strokeStyle = '#1976D2';
|
|
|
|
|
+ ctx.lineWidth = 1.2;
|
|
|
|
|
+ ctx.fillStyle = '#1976D2';
|
|
|
|
|
+ ctx.font = 'bold 11px -apple-system, sans-serif';
|
|
|
|
|
+
|
|
|
|
|
+ // X 轴
|
|
|
|
|
+ if (origin.y >= m && origin.y <= m + ch) {
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ ctx.moveTo(m, origin.y);
|
|
|
|
|
+ ctx.lineTo(m + cw, origin.y);
|
|
|
|
|
+ ctx.stroke();
|
|
|
|
|
+ // 箭头
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ ctx.moveTo(m + cw, origin.y);
|
|
|
|
|
+ ctx.lineTo(m + cw - 8, origin.y - 4);
|
|
|
|
|
+ ctx.lineTo(m + cw - 8, origin.y + 4);
|
|
|
|
|
+ ctx.fill();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Y 轴
|
|
|
|
|
+ if (origin.x >= m && origin.x <= m + cw) {
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ ctx.moveTo(origin.x, m + ch);
|
|
|
|
|
+ ctx.lineTo(origin.x, m);
|
|
|
|
|
+ ctx.stroke();
|
|
|
|
|
+ // 箭头
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ ctx.moveTo(origin.x, m);
|
|
|
|
|
+ ctx.lineTo(origin.x - 4, m + 8);
|
|
|
|
|
+ ctx.lineTo(origin.x + 4, m + 8);
|
|
|
|
|
+ ctx.fill();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 原点标签
|
|
|
|
|
+ if (origin.x >= m && origin.x <= m + cw && origin.y >= m && origin.y <= m + ch) {
|
|
|
|
|
+ ctx.textAlign = 'right';
|
|
|
|
|
+ ctx.fillText('O', origin.x - 6, origin.y + 14);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ niceStep(range, target) {
|
|
|
|
|
+ const rough = range / target;
|
|
|
|
|
+ const exp = Math.pow(10, Math.floor(Math.log10(rough)));
|
|
|
|
|
+ const mant = rough / exp;
|
|
|
|
|
+ let nice = 1;
|
|
|
|
|
+ if (mant <= 1.5) nice = 1;
|
|
|
|
|
+ else if (mant <= 3.5) nice = 2;
|
|
|
|
|
+ else if (mant <= 7.5) nice = 5;
|
|
|
|
|
+ else nice = 10;
|
|
|
|
|
+ return nice * exp;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fmtNum(n) {
|
|
|
|
|
+ if (Math.abs(n) < 1e-10) return '0';
|
|
|
|
|
+ if (Math.abs(n) < 0.001 || Math.abs(n) >= 1e6) return n.toExponential(2);
|
|
|
|
|
+ if (Number.isInteger(n)) return n.toString();
|
|
|
|
|
+ const s = n.toFixed(4);
|
|
|
|
|
+ return s.replace(/0+$/, '').replace(/\.$/, '');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ── 手势 ──────────────────────────────────────────
|
|
|
|
|
+ bindGestures() {
|
|
|
|
|
+ const canvas = this.canvas;
|
|
|
|
|
+ let lastDist = 0, lastX = 0, lastY = 0;
|
|
|
|
|
+ let lastTap = 0;
|
|
|
|
|
+
|
|
|
|
|
+ canvas.addEventListener('touchstart', (e) => {
|
|
|
|
|
+ if (e.touches.length === 2) {
|
|
|
|
|
+ lastDist = Math.hypot(
|
|
|
|
|
+ e.touches[0].clientX - e.touches[1].clientX,
|
|
|
|
|
+ e.touches[0].clientY - e.touches[1].clientY
|
|
|
|
|
+ );
|
|
|
|
|
+ } else if (e.touches.length === 1) {
|
|
|
|
|
+ lastX = e.touches[0].clientX;
|
|
|
|
|
+ lastY = e.touches[0].clientY;
|
|
|
|
|
+ }
|
|
|
|
|
+ e.preventDefault();
|
|
|
|
|
+ }, { passive: false });
|
|
|
|
|
+
|
|
|
|
|
+ canvas.addEventListener('touchmove', (e) => {
|
|
|
|
|
+ if (e.touches.length === 2) {
|
|
|
|
|
+ const dist = Math.hypot(
|
|
|
|
|
+ e.touches[0].clientX - e.touches[1].clientX,
|
|
|
|
|
+ e.touches[0].clientY - e.touches[1].clientY
|
|
|
|
|
+ );
|
|
|
|
|
+ const scale = dist / lastDist;
|
|
|
|
|
+ this.transform.sx = Math.max(0.1, Math.min(20, this.transform.sx / scale));
|
|
|
|
|
+ this.transform.sy = Math.max(0.1, Math.min(20, this.transform.sy / scale));
|
|
|
|
|
+ lastDist = dist;
|
|
|
|
|
+ this.draw();
|
|
|
|
|
+ } else if (e.touches.length === 1) {
|
|
|
|
|
+ const dx = (e.touches[0].clientX - lastX) / canvas.clientWidth;
|
|
|
|
|
+ const dy = (e.touches[0].clientY - lastY) / canvas.clientHeight;
|
|
|
|
|
+ this.transform.ox -= dx / this.transform.sx;
|
|
|
|
|
+ this.transform.oy += dy / this.transform.sy;
|
|
|
|
|
+ lastX = e.touches[0].clientX;
|
|
|
|
|
+ lastY = e.touches[0].clientY;
|
|
|
|
|
+ this.draw();
|
|
|
|
|
+ }
|
|
|
|
|
+ e.preventDefault();
|
|
|
|
|
+ }, { passive: false });
|
|
|
|
|
+
|
|
|
|
|
+ canvas.addEventListener('touchend', (e) => {
|
|
|
|
|
+ const now = Date.now();
|
|
|
|
|
+ if (now - lastTap < 300) {
|
|
|
|
|
+ // 双击重置
|
|
|
|
|
+ this.transform = { ox: 0, oy: 0, sx: 1, sy: 1 };
|
|
|
|
|
+ this.draw();
|
|
|
|
|
+ }
|
|
|
|
|
+ lastTap = now;
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+}
|