Files
smart_wasm/public/fourbar_test.html
2026-06-16 10:48:36 +08:00

1159 lines
35 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>四连杆/曲柄滑块 WASM 验证台</title>
<style>
:root {
--bg: #f5f7f8;
--surface: #ffffff;
--surface-soft: #eef2f5;
--line: #cdd7df;
--text: #17212b;
--muted: #64717d;
--primary: #0f6f8f;
--primary-strong: #0a526b;
--accent: #946200;
--danger: #b42318;
--ok: #157347;
--code-bg: #111820;
--code-text: #edf6fb;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font-family:
"Segoe UI",
"Microsoft YaHei",
Arial,
sans-serif;
}
button,
input,
textarea {
font: inherit;
}
button {
min-height: 44px;
border: 1px solid transparent;
border-radius: 8px;
padding: 10px 14px;
font-weight: 650;
cursor: pointer;
transition:
background-color 160ms ease,
border-color 160ms ease,
color 160ms ease;
}
button:focus-visible,
input:focus-visible,
textarea:focus-visible {
outline: 3px solid rgba(15, 111, 143, 0.25);
outline-offset: 2px;
}
button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.primary {
background: var(--primary);
color: #ffffff;
}
.primary:hover:not(:disabled) {
background: var(--primary-strong);
}
.secondary {
background: #ffffff;
color: var(--text);
border-color: var(--line);
}
.secondary:hover:not(:disabled) {
border-color: var(--primary);
color: var(--primary-strong);
}
.shell {
width: min(1460px, 100%);
margin: 0 auto;
padding: 24px;
}
.topbar {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
h1,
h2,
h3,
p {
margin: 0;
}
h1 {
font-size: 28px;
line-height: 1.2;
}
.subtitle {
margin-top: 6px;
color: var(--muted);
font-size: 14px;
}
.status,
.badge {
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface);
color: var(--muted);
font-size: 13px;
}
.status {
min-width: 156px;
padding: 8px 10px;
text-align: center;
}
.badge {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 4px 8px;
font-weight: 650;
}
.badge.ok {
border-color: rgba(21, 115, 71, 0.3);
background: rgba(21, 115, 71, 0.08);
color: var(--ok);
}
.badge.warn {
border-color: rgba(148, 98, 0, 0.35);
background: rgba(148, 98, 0, 0.08);
color: var(--accent);
}
.badge.error {
border-color: rgba(180, 35, 24, 0.35);
background: rgba(180, 35, 24, 0.08);
color: var(--danger);
}
.layout {
display: grid;
grid-template-columns: minmax(360px, 0.72fr) minmax(600px, 1.28fr);
gap: 16px;
align-items: start;
}
.panel {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 8px;
overflow: hidden;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid var(--line);
background: var(--surface-soft);
}
.panel-header h2 {
font-size: 17px;
}
.panel-body {
padding: 16px;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.field {
display: grid;
gap: 6px;
}
label {
color: var(--muted);
font-size: 13px;
font-weight: 600;
}
input,
textarea {
width: 100%;
border: 1px solid var(--line);
border-radius: 8px;
color: var(--text);
background: #ffffff;
}
input {
min-height: 44px;
padding: 10px 11px;
}
textarea {
min-height: 360px;
resize: vertical;
padding: 12px;
font:
13px/1.5 Consolas,
"Courier New",
monospace;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 14px;
}
.message {
margin-top: 12px;
min-height: 20px;
color: var(--muted);
font-size: 13px;
}
.message.is-ok {
color: var(--ok);
}
.message.is-error {
color: var(--danger);
}
.summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.metric {
min-height: 88px;
border: 1px solid var(--line);
border-radius: 8px;
padding: 12px;
background: #ffffff;
}
.metric-label {
color: var(--muted);
font-size: 12px;
font-weight: 650;
}
.metric-value {
margin-top: 8px;
color: var(--text);
font-size: 22px;
font-weight: 750;
overflow-wrap: anywhere;
}
.metric-value.ok {
color: var(--ok);
}
.metric-value.error {
color: var(--danger);
}
.chart-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.chart-card {
border: 1px solid var(--line);
border-radius: 8px;
background: #ffffff;
overflow: hidden;
}
.chart-card.wide {
grid-column: 1 / -1;
}
.chart-title {
display: flex;
justify-content: space-between;
gap: 10px;
padding: 12px 14px;
border-bottom: 1px solid var(--line);
color: var(--text);
font-size: 14px;
font-weight: 700;
}
.chart-title span:last-child {
color: var(--muted);
font-weight: 650;
}
.chart {
width: 100%;
height: 360px;
}
.chart.compact {
height: 300px;
}
.chart.empty {
display: grid;
place-items: center;
color: var(--muted);
font-size: 14px;
}
.chart-note {
min-height: 84px;
padding: 12px 14px 14px;
border-top: 1px solid var(--line);
color: var(--muted);
font-size: 13px;
line-height: 1.55;
}
.chart-note strong {
color: var(--text);
}
.table-wrap {
margin-top: 16px;
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
min-width: 720px;
font-size: 13px;
}
th,
td {
border-bottom: 1px solid var(--line);
padding: 10px 8px;
text-align: left;
vertical-align: top;
}
th {
color: var(--muted);
font-weight: 700;
background: var(--surface-soft);
}
.tabs {
display: flex;
gap: 8px;
margin-top: 16px;
border-bottom: 1px solid var(--line);
}
.tab {
min-height: 40px;
border-color: var(--line);
border-bottom: 0;
border-radius: 8px 8px 0 0;
background: #ffffff;
color: var(--muted);
}
.tab.active {
background: var(--primary);
color: #ffffff;
}
pre {
margin: 0;
max-height: 420px;
overflow: auto;
border-radius: 0 0 8px 8px;
padding: 14px;
background: var(--code-bg);
color: var(--code-text);
font:
12px/1.5 Consolas,
"Courier New",
monospace;
}
.hidden {
display: none;
}
@media (max-width: 1000px) {
.topbar {
align-items: stretch;
flex-direction: column;
}
.status {
width: 100%;
}
.layout {
grid-template-columns: 1fr;
}
.summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.chart-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 560px) {
.shell {
padding: 16px;
}
.grid,
.summary {
grid-template-columns: 1fr;
}
h1 {
font-size: 24px;
}
}
</style>
</head>
<body>
<main class="shell">
<header class="topbar">
<div>
<h1>四连杆/曲柄滑块 WASM 验证台</h1>
<p class="subtitle">Cmd_FourBar_CrankSlider · 点位 / 姿态 / 轨迹</p>
</div>
<div class="status" id="wasmStatus">WASM 未加载</div>
</header>
<section class="layout">
<div class="panel">
<div class="panel-header">
<h2>输入</h2>
<span class="badge" id="requestStatus">未执行</span>
</div>
<div class="panel-body">
<div class="grid">
<div class="field">
<label for="lAbInput">L_AB 曲柄长度</label>
<input id="lAbInput" type="number" min="0" step="0.001" />
</div>
<div class="field">
<label for="lBsInput">L_BS 连杆长度</label>
<input id="lBsInput" type="number" min="0" step="0.001" />
</div>
<div class="field">
<label for="sOfsInput">S_OFS 滑块偏移</label>
<input id="sOfsInput" type="number" step="0.001" />
</div>
<div class="field">
<label for="angleInput">angleDeg 曲柄角度</label>
<input id="angleInput" type="number" step="1" />
</div>
<div class="field">
<label for="stepInput">扫描步长</label>
<input id="stepInput" type="number" min="1" max="90" step="1" />
</div>
<div class="field">
<label for="scanEndInput">扫描终止角</label>
<input id="scanEndInput" type="number" min="1" max="720" step="1" />
</div>
</div>
<div class="actions">
<button class="secondary" id="validButton" type="button">有效样例</button>
<button class="secondary" id="invalidButton" type="button">异常样例</button>
<button class="primary" id="runButton" type="button">调用接口</button>
<button class="primary" id="scanButton" type="button">角度扫描</button>
</div>
<div class="message" id="inputMessage" aria-live="polite"></div>
<div class="field" style="margin-top: 16px">
<label for="requestOutput">请求 JSON</label>
<textarea id="requestOutput" spellcheck="false"></textarea>
</div>
</div>
</div>
<div class="panel">
<div class="panel-header">
<h2>结果</h2>
<span class="badge" id="verifyStatus">未执行</span>
</div>
<div class="panel-body">
<div class="summary">
<div class="metric">
<div class="metric-label">顶层 success</div>
<div class="metric-value" id="topSuccess">-</div>
</div>
<div class="metric">
<div class="metric-label">业务 success</div>
<div class="metric-value" id="businessSuccess">-</div>
</div>
<div class="metric">
<div class="metric-label">滑块 X</div>
<div class="metric-value" id="sliderX">-</div>
</div>
<div class="metric">
<div class="metric-label">完整性</div>
<div class="metric-value" id="completeStatus">-</div>
</div>
</div>
<div class="chart-grid" aria-label="曲柄滑块图形结果">
<div class="chart-card wide">
<div class="chart-title">
<span>机构当前姿态</span>
<span>A-B-S</span>
</div>
<div class="chart empty" id="mechanismChart">等待执行</div>
<div class="chart-note">
<strong>机构当前姿态</strong>
显示固定铰点 A、曲柄端点 B 和滑块 S 的二维位置。A-B 是曲柄B-S 是连杆S 点落在 y=S_OFS 的滑块导轨上,用于核对单角度计算结果是否符合几何关系。
</div>
</div>
<div class="chart-card">
<div class="chart-title">
<span>B 点圆轨迹</span>
<span>Crank path</span>
</div>
<div class="chart compact empty" id="crankChart">等待扫描</div>
<div class="chart-note">
<strong>B 点圆轨迹</strong>
由测试页按角度多次调用接口后汇总生成,理论上应接近以 A 为圆心、L_AB 为半径的圆。它适合验证曲柄长度、角度单位和旋转方向。
</div>
</div>
<div class="chart-card">
<div class="chart-title">
<span>滑块位移曲线</span>
<span>S.x / angle</span>
</div>
<div class="chart compact empty" id="sliderChart">等待扫描</div>
<div class="chart-note">
<strong>滑块位移曲线</strong>
横轴为输入角度,纵轴为接口返回的 S 点 x 坐标。曲线可用于检查连杆长度、偏置量和极限位置处的运动连续性。
</div>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>检查项</th>
<th>结果</th>
<th>说明</th>
</tr>
</thead>
<tbody id="checkBody">
<tr>
<td colspan="3">等待执行</td>
</tr>
</tbody>
</table>
</div>
<div class="tabs">
<button class="tab active" id="responseTab" type="button">响应 JSON</button>
<button class="tab" id="scanTab" type="button">扫描 JSON</button>
</div>
<pre id="responseOutput">No output yet.</pre>
<pre class="hidden" id="scanOutput">No scan yet.</pre>
</div>
</div>
</section>
</main>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.1/dist/echarts.min.js"></script>
<script src="./wasm/smart_math.js"></script>
<script>
const validSample = {
L_AB: 0.5,
L_BS: 2.0,
S_OFS: 0.0,
angleDeg: 45,
};
const invalidSample = {
L_AB: 0,
L_BS: 0.2,
S_OFS: 0.5,
angleDeg: 30,
};
let wasmModule = null;
let charts = null;
let latestResponse = null;
let latestScan = [];
const wasmStatus = document.getElementById("wasmStatus");
const requestStatus = document.getElementById("requestStatus");
const verifyStatus = document.getElementById("verifyStatus");
const inputMessage = document.getElementById("inputMessage");
const lAbInput = document.getElementById("lAbInput");
const lBsInput = document.getElementById("lBsInput");
const sOfsInput = document.getElementById("sOfsInput");
const angleInput = document.getElementById("angleInput");
const stepInput = document.getElementById("stepInput");
const scanEndInput = document.getElementById("scanEndInput");
const requestOutput = document.getElementById("requestOutput");
const topSuccess = document.getElementById("topSuccess");
const businessSuccess = document.getElementById("businessSuccess");
const sliderX = document.getElementById("sliderX");
const completeStatus = document.getElementById("completeStatus");
const checkBody = document.getElementById("checkBody");
const responseOutput = document.getElementById("responseOutput");
const scanOutput = document.getElementById("scanOutput");
const responseTab = document.getElementById("responseTab");
const scanTab = document.getElementById("scanTab");
const chartElements = {
mechanism: document.getElementById("mechanismChart"),
crank: document.getElementById("crankChart"),
slider: document.getElementById("sliderChart"),
};
// 设置页面提示文案和语义状态。
function setMessage(message, className = "") {
inputMessage.textContent = message;
inputMessage.className = `message ${className}`.trim();
}
// 保留固定小数位,便于表格和图形标签核对。
function fmt(value, digits = 6) {
return Number.isFinite(Number(value)) ? Number(value).toFixed(digits) : "-";
}
// 从页面读取并校验曲柄滑块接口参数。
function readInput() {
const input = {
L_AB: Number(lAbInput.value),
L_BS: Number(lBsInput.value),
S_OFS: Number(sOfsInput.value),
angleDeg: Number(angleInput.value),
};
if (!Object.values(input).every(Number.isFinite)) {
throw new Error("所有机构参数都必须是有效数字");
}
return input;
}
// 按 WASM 统一入口格式组装四连杆请求。
function buildRequest(input, reqCode = `FOURBAR_VERIFY_${Date.now()}`) {
return {
msg: "fourbar crank slider verify",
req_code: reqCode,
req_from: "fourbar_test_page",
req_cmd: "Cmd_FourBar_CrankSlider",
req_param: input,
};
}
// 将当前表单同步到请求 JSON 文本框。
function refreshRequestPreview() {
try {
requestOutput.value = JSON.stringify(buildRequest(readInput()), null, 2);
} catch {
requestOutput.value = "";
}
}
// 填入内置样例并刷新请求预览。
function loadSample(sample) {
lAbInput.value = sample.L_AB;
lBsInput.value = sample.L_BS;
sOfsInput.value = sample.S_OFS;
angleInput.value = sample.angleDeg;
stepInput.value = stepInput.value || "10";
scanEndInput.value = scanEndInput.value || "360";
refreshRequestPreview();
}
// 懒初始化 ECharts 实例。
function ensureCharts() {
if (!window.echarts) {
throw new Error("ECharts 加载失败,请检查网络或 CDN 可用性");
}
if (charts) return charts;
Object.values(chartElements).forEach((element) => {
element.classList.remove("empty");
element.textContent = "";
});
charts = {
mechanism: echarts.init(chartElements.mechanism),
crank: echarts.init(chartElements.crank),
slider: echarts.init(chartElements.slider),
};
window.addEventListener("resize", () => {
Object.values(charts).forEach((chart) => chart.resize());
});
return charts;
}
// 加载并初始化 WASM 模块。
async function ensureWasm() {
if (wasmModule) return wasmModule;
wasmStatus.textContent = "WASM 加载中";
wasmModule = await createSmartMathModule();
const ptr = wasmModule._init_func();
try {
wasmModule.UTF8ToString(ptr);
} finally {
wasmModule._smart_free_string(ptr);
}
wasmStatus.textContent = "WASM 已初始化";
return wasmModule;
}
// 在 WASM 内存中写入字符串。
function allocString(module, value) {
const size = module.lengthBytesUTF8(value) + 1;
const ptr = module._malloc(size);
module.stringToUTF8(value, ptr, size);
return ptr;
}
// 调用统一业务接口并解析 JSON 响应。
async function callFourBar(input, reqCode) {
const module = await ensureWasm();
const payload = JSON.stringify(buildRequest(input, reqCode));
let requestPtr = 0;
let responsePtr = 0;
try {
requestPtr = allocString(module, payload);
responsePtr = module._func(requestPtr);
return JSON.parse(module.UTF8ToString(responsePtr));
} finally {
if (responsePtr) module._smart_free_string(responsePtr);
if (requestPtr) module._free(requestPtr);
}
}
// 计算前端几何校验值,用于和 WASM 返回点位对照。
function calculateLocalGeometry(input) {
const angle = (input.angleDeg * Math.PI) / 180;
const pointA = { x: 0, y: 0 };
const pointB = {
x: input.L_AB * Math.cos(angle),
y: input.L_AB * Math.sin(angle),
};
const dx = Math.sqrt(
Math.max(0, input.L_BS ** 2 - (input.S_OFS - pointB.y) ** 2),
);
const pointS = { x: pointB.x + dx, y: input.S_OFS };
return { pointA, pointB, pointS };
}
// 判断有效响应是否包含曲柄滑块验证需要的核心字段。
function inspectResponse(response) {
const resData = response && response.res_data ? response.res_data : {};
const points = resData.points || {};
const poses = resData.poses || {};
const hasError = Boolean(resData.error);
const requiredPoints = ["A", "B", "S"];
const requiredPoses = ["AB", "BS", "TCP"];
const missingPoints = requiredPoints.filter((key) => !points[key]);
const missingPoses = requiredPoses.filter((key) => !poses[key]);
const hasAngles = Boolean(resData.angles && resData.angles["曲柄角度"] !== undefined);
return {
resData,
hasError,
missingPoints,
missingPoses,
hasAngles,
complete:
response.success === true &&
resData.success === true &&
!hasError &&
missingPoints.length === 0 &&
missingPoses.length === 0 &&
hasAngles,
};
}
// 更新顶部摘要指标。
function renderSummary(response) {
const check = inspectResponse(response);
const sPoint = check.resData.points && check.resData.points.S;
topSuccess.textContent = String(Boolean(response.success));
topSuccess.className = `metric-value ${response.success ? "ok" : "error"}`;
businessSuccess.textContent = String(Boolean(check.resData.success));
businessSuccess.className = `metric-value ${check.resData.success ? "ok" : "error"}`;
sliderX.textContent = sPoint ? fmt(sPoint.x, 5) : "-";
completeStatus.textContent = check.complete ? "完整" : "异常";
completeStatus.className = `metric-value ${check.complete ? "ok" : "error"}`;
verifyStatus.textContent = check.complete ? "接口完整" : check.hasError ? "业务失败" : "需核对";
verifyStatus.className = `badge ${check.complete ? "ok" : check.hasError ? "error" : "warn"}`;
}
// 渲染字段完整性和几何误差检查表。
function renderChecks(response, input) {
const check = inspectResponse(response);
const rows = [];
rows.push({
name: "顶层 success",
ok: response.success === true,
desc: `实际值:${String(response.success)},业务失败时这里应为 false`,
});
rows.push({
name: "业务 error",
ok: !check.hasError,
desc: check.hasError ? check.resData.error : "res_data.error 不存在",
});
rows.push({
name: "点位完整性",
ok: check.missingPoints.length === 0,
desc: check.missingPoints.length
? `缺少:${check.missingPoints.join(", ")}`
: "A / B / S 均已返回",
});
rows.push({
name: "姿态完整性",
ok: check.missingPoses.length === 0,
desc: check.missingPoses.length
? `缺少:${check.missingPoses.join(", ")}`
: "AB / BS / TCP 均已返回",
});
rows.push({
name: "角度字段",
ok: check.hasAngles,
desc: check.hasAngles
? `曲柄角度=${fmt(check.resData.angles["曲柄角度"], 3)}`
: "缺少 res_data.angles.曲柄角度",
});
if (check.resData.success && check.resData.points) {
const local = calculateLocalGeometry(input);
const b = check.resData.points.B;
const s = check.resData.points.S;
const bDiff = Math.hypot(b.x - local.pointB.x, b.y - local.pointB.y);
const sDiff = Math.hypot(s.x - local.pointS.x, s.y - local.pointS.y);
rows.push({
name: "B 点几何误差",
ok: bDiff <= 0.000001,
desc: `接口 B=(${fmt(b.x)}, ${fmt(b.y)}),前端复算误差=${fmt(bDiff)}`,
});
rows.push({
name: "S 点几何误差",
ok: sDiff <= 0.000001,
desc: `接口 S=(${fmt(s.x)}, ${fmt(s.y)}),前端复算误差=${fmt(sDiff)}`,
});
}
checkBody.innerHTML = rows
.map(
(row) => `
<tr>
<td>${row.name}</td>
<td><span class="badge ${row.ok ? "ok" : "error"}">${row.ok ? "通过" : "失败"}</span></td>
<td>${row.desc}</td>
</tr>
`,
)
.join("");
}
// 渲染当前机构姿态图。
function renderMechanismChart(response, input) {
const chartMap = ensureCharts();
const data = response.res_data || {};
const points = data.points || calculateLocalGeometry(input);
const a = points.A || points.pointA;
const b = points.B || points.pointB;
const s = points.S || points.pointS;
const margin = Math.max(input.L_AB, input.L_BS, Math.abs(input.S_OFS), 0.5) * 0.4;
const minX = Math.min(a.x, b.x, s.x) - margin;
const maxX = Math.max(a.x, b.x, s.x) + margin;
const minY = Math.min(a.y, b.y, s.y, input.S_OFS) - margin;
const maxY = Math.max(a.y, b.y, s.y, input.S_OFS) + margin;
chartMap.mechanism.setOption(
{
color: ["#0f6f8f", "#946200", "#157347", "#64717d"],
tooltip: {
trigger: "item",
formatter: (params) => {
if (params.seriesType === "scatter") {
return `${params.name}<br/>x=${fmt(params.value[0], 5)}<br/>y=${fmt(params.value[1], 5)}`;
}
return params.seriesName;
},
},
grid: { left: 52, right: 28, top: 32, bottom: 42 },
xAxis: { type: "value", min: minX, max: maxX, scale: true, name: "x" },
yAxis: { type: "value", min: minY, max: maxY, scale: true, name: "y" },
series: [
{
name: "曲柄 AB",
type: "line",
data: [
[a.x, a.y],
[b.x, b.y],
],
symbol: "none",
lineStyle: { width: 4 },
},
{
name: "连杆 BS",
type: "line",
data: [
[b.x, b.y],
[s.x, s.y],
],
symbol: "none",
lineStyle: { width: 4 },
},
{
name: "关键点",
type: "scatter",
symbolSize: 12,
data: [
{ name: "A 固定铰点", value: [a.x, a.y] },
{ name: "B 曲柄端点", value: [b.x, b.y] },
{ name: "S 滑块", value: [s.x, s.y] },
],
label: {
show: true,
formatter: (params) => params.name.slice(0, 1),
position: "top",
},
},
{
name: "滑块导轨",
type: "line",
data: [
[minX, input.S_OFS],
[maxX, input.S_OFS],
],
symbol: "none",
lineStyle: { type: "dashed", width: 2, color: "#64717d" },
},
],
},
true,
);
}
// 渲染角度扫描汇总图。
function renderScanCharts(scanRows) {
const chartMap = ensureCharts();
const validRows = scanRows.filter((row) => row.ok);
const crankData = validRows.map((row) => [row.B.x, row.B.y, row.angle]);
const sliderData = validRows.map((row) => [row.angle, row.S.x]);
chartMap.crank.setOption(
{
color: ["#0f6f8f"],
tooltip: {
trigger: "item",
formatter: (params) =>
`角度=${fmt(params.value[2], 2)}<br/>B.x=${fmt(params.value[0], 5)}<br/>B.y=${fmt(params.value[1], 5)}`,
},
grid: { left: 52, right: 28, top: 30, bottom: 42 },
xAxis: { type: "value", scale: true, name: "B.x" },
yAxis: { type: "value", scale: true, name: "B.y" },
series: [
{
name: "B 点轨迹",
type: "line",
data: crankData,
showSymbol: false,
lineStyle: { width: 3 },
},
],
},
true,
);
chartMap.slider.setOption(
{
color: ["#946200"],
tooltip: {
trigger: "axis",
formatter: (items) => {
const item = items[0];
return `角度=${fmt(item.value[0], 2)}<br/>S.x=${fmt(item.value[1], 5)}`;
},
},
grid: { left: 52, right: 28, top: 30, bottom: 42 },
xAxis: { type: "value", name: "angleDeg" },
yAxis: { type: "value", scale: true, name: "S.x" },
series: [
{
name: "滑块位移",
type: "line",
data: sliderData,
showSymbol: false,
lineStyle: { width: 3 },
},
],
},
true,
);
}
// 执行当前角度的单次接口验证。
async function runOnce() {
try {
setMessage("");
requestStatus.textContent = "执行中";
requestStatus.className = "badge warn";
const input = readInput();
refreshRequestPreview();
const response = await callFourBar(input);
latestResponse = response;
responseOutput.textContent = JSON.stringify(response, null, 2);
renderSummary(response);
renderChecks(response, input);
if (response.res_data && response.res_data.success) {
renderMechanismChart(response, input);
setMessage("接口调用完成,当前姿态已刷新", "is-ok");
} else {
renderMechanismChart({ res_data: { points: calculateLocalGeometry(input) } }, input);
setMessage(response.res_data?.error || response.msg || "接口返回业务失败", "is-error");
}
requestStatus.textContent = "已执行";
requestStatus.className = "badge ok";
} catch (error) {
requestStatus.textContent = "执行失败";
requestStatus.className = "badge error";
verifyStatus.textContent = "执行失败";
verifyStatus.className = "badge error";
setMessage(error.message, "is-error");
}
}
// 多角度调用接口,生成轨迹和位移曲线。
async function runScan() {
try {
setMessage("");
const baseInput = readInput();
const step = Number(stepInput.value);
const scanEnd = Number(scanEndInput.value);
if (!Number.isInteger(step) || step < 1 || step > 90) {
throw new Error("扫描步长必须是 1 到 90 之间的整数");
}
if (!Number.isFinite(scanEnd) || scanEnd <= 0 || scanEnd > 720) {
throw new Error("扫描终止角必须在 1 到 720 之间");
}
requestStatus.textContent = "扫描中";
requestStatus.className = "badge warn";
const rows = [];
for (let angle = 0; angle <= scanEnd + 0.000001; angle += step) {
const input = { ...baseInput, angleDeg: angle };
const response = await callFourBar(input, `FOURBAR_SCAN_${angle}`);
const data = response.res_data || {};
rows.push({
angle,
ok: response.success === true && data.success === true && !data.error,
B: data.points?.B,
S: data.points?.S,
error: data.error || response.msg || "",
});
}
latestScan = rows;
scanOutput.textContent = JSON.stringify(rows, null, 2);
renderScanCharts(rows);
requestStatus.textContent = "扫描完成";
requestStatus.className = "badge ok";
setMessage(`扫描完成:${rows.filter((row) => row.ok).length}/${rows.length} 个角度有效`, "is-ok");
} catch (error) {
requestStatus.textContent = "扫描失败";
requestStatus.className = "badge error";
setMessage(error.message, "is-error");
}
}
// 切换响应与扫描 JSON 详情。
function setActiveTab(name) {
const isResponse = name === "response";
responseTab.classList.toggle("active", isResponse);
scanTab.classList.toggle("active", !isResponse);
responseOutput.classList.toggle("hidden", !isResponse);
scanOutput.classList.toggle("hidden", isResponse);
}
[lAbInput, lBsInput, sOfsInput, angleInput].forEach((input) => {
input.addEventListener("input", refreshRequestPreview);
});
document.getElementById("validButton").addEventListener("click", () => {
loadSample(validSample);
setMessage("有效样例已填入", "is-ok");
});
document.getElementById("invalidButton").addEventListener("click", () => {
loadSample(invalidSample);
setMessage("异常样例已填入", "is-ok");
});
document.getElementById("runButton").addEventListener("click", runOnce);
document.getElementById("scanButton").addEventListener("click", runScan);
responseTab.addEventListener("click", () => setActiveTab("response"));
scanTab.addEventListener("click", () => setActiveTab("scan"));
loadSample(validSample);
stepInput.value = "10";
scanEndInput.value = "360";
refreshRequestPreview();
</script>
</body>
</html>