Files
smart_wasm/public/spc_test.html
2026-06-16 10:29:51 +08:00

1682 lines
53 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>SPC WASM 验证台</title>
<style>
:root {
--bg: #f6f7f4;
--surface: #ffffff;
--surface-soft: #eef3f1;
--line: #cfd8d3;
--text: #1b2522;
--muted: #65736e;
--primary: #126b5c;
--primary-strong: #0b4f44;
--accent: #b45f06;
--danger: #b42318;
--warning: #8a5a00;
--ok: #157347;
--code-bg: #111917;
--code-text: #edf7f3;
}
* {
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(18, 107, 92, 0.28);
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(1440px, 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 {
min-width: 156px;
border: 1px solid var(--line);
border-radius: 8px;
padding: 8px 10px;
background: var(--surface);
color: var(--muted);
font-size: 13px;
text-align: center;
}
.layout {
display: grid;
grid-template-columns: minmax(360px, 0.9fr) minmax(520px, 1.1fr);
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(4, 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: 300px;
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-error {
color: var(--danger);
}
.message.is-ok {
color: var(--ok);
}
.summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin-bottom: 16px;
}
.metric {
border: 1px solid var(--line);
border-radius: 8px;
padding: 11px 12px;
background: #ffffff;
}
.metric-label {
color: var(--muted);
font-size: 12px;
font-weight: 650;
}
.metric-value {
margin-top: 6px;
font-size: 20px;
font-variant-numeric: tabular-nums;
font-weight: 750;
}
.metric-value.ok {
color: var(--ok);
}
.metric-value.warn {
color: var(--warning);
}
.table-wrap {
overflow: auto;
border: 1px solid var(--line);
border-radius: 8px;
}
.chart-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.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;
align-items: center;
justify-content: space-between;
min-height: 42px;
padding: 10px 12px;
border-bottom: 1px solid var(--line);
background: #f7faf8;
color: var(--muted);
font-size: 13px;
font-weight: 750;
}
.chart {
width: 100%;
height: 300px;
}
.chart-note {
padding: 0 12px 12px;
color: var(--muted);
font-size: 13px;
line-height: 1.55;
}
.chart-note strong {
color: var(--text);
font-weight: 750;
}
.chart.empty {
display: grid;
place-items: center;
color: var(--muted);
font-size: 13px;
}
table {
width: 100%;
border-collapse: collapse;
min-width: 760px;
background: #ffffff;
}
th,
td {
padding: 10px 12px;
border-bottom: 1px solid var(--line);
text-align: left;
font-size: 13px;
vertical-align: middle;
}
th {
background: #f2f5f3;
color: var(--muted);
font-weight: 750;
}
td {
font-variant-numeric: tabular-nums;
}
tr:last-child td {
border-bottom: 0;
}
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 26px;
border-radius: 999px;
padding: 3px 9px;
border: 1px solid var(--line);
background: #ffffff;
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.badge.ok {
border-color: rgba(21, 115, 71, 0.28);
background: rgba(21, 115, 71, 0.08);
color: var(--ok);
}
.badge.warn {
border-color: rgba(180, 35, 24, 0.28);
background: rgba(180, 35, 24, 0.08);
color: var(--danger);
}
.tabs {
display: flex;
gap: 8px;
margin: 16px 0 10px;
}
.tab {
min-height: 36px;
padding: 7px 12px;
background: #ffffff;
border-color: var(--line);
color: var(--muted);
}
.tab.active {
background: var(--primary);
border-color: var(--primary);
color: #ffffff;
}
pre {
min-height: 260px;
max-height: 480px;
margin: 0;
overflow: auto;
border-radius: 8px;
padding: 14px;
background: var(--code-bg);
color: var(--code-text);
font:
13px/1.5 Consolas,
"Courier New",
monospace;
}
.hidden {
display: none;
}
@media (max-width: 980px) {
.shell {
padding: 16px;
}
.topbar {
align-items: stretch;
flex-direction: column;
}
.status {
width: 100%;
}
.layout {
grid-template-columns: 1fr;
}
.grid,
.summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.chart-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 560px) {
.grid,
.summary {
grid-template-columns: 1fr;
}
h1 {
font-size: 24px;
}
}
</style>
</head>
<body>
<main class="shell">
<header class="topbar">
<div>
<h1>SPC WASM 验证台</h1>
<p class="subtitle">Cmd_Spc · XR / XS / Cpk</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="inputCount">0 个数据</span>
</div>
<div class="panel-body">
<div class="grid">
<div class="field">
<label for="nInput">n</label>
<input id="nInput" type="number" min="2" max="25" step="1" />
</div>
<div class="field">
<label for="kInput">k</label>
<input id="kInput" type="number" min="1" step="1" />
</div>
<div class="field">
<label for="uslInput">USL</label>
<input id="uslInput" type="number" step="0.001" />
</div>
<div class="field">
<label for="lslInput">LSL</label>
<input id="lslInput" type="number" step="0.001" />
</div>
</div>
<div class="field" style="margin-top: 14px">
<label for="dataInput">x</label>
<textarea id="dataInput" spellcheck="false"></textarea>
</div>
<div class="actions">
<button class="secondary" id="sampleButton" type="button">
填入样例
</button>
<button class="secondary" id="formatButton" type="button">
格式化数据
</button>
<button class="primary" id="runButton" type="button">
调用并验证
</button>
</div>
<div class="message" id="inputMessage" aria-live="polite"></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">匹配项</div>
<div class="metric-value" id="matchedCount">0</div>
</div>
<div class="metric">
<div class="metric-label">最大差值</div>
<div class="metric-value" id="maxDiff">-</div>
</div>
<div class="metric">
<div class="metric-label">WASM Cpk</div>
<div class="metric-value" id="wasmCpk">-</div>
</div>
<div class="metric">
<div class="metric-label">前端 Cpk</div>
<div class="metric-value" id="localCpk">-</div>
</div>
</div>
<div class="chart-grid" aria-label="SPC 图形结果">
<div class="chart-card wide">
<div class="chart-title">
<span>Xbar-R 控制图</span>
<span>XR</span>
</div>
<div class="chart empty" id="xrChart">等待执行</div>
<div class="chart-note">
<strong>Xbar-R</strong>
用子组均值观察过程中心,用子组极差观察组内波动。适合子组容量较小的连续型测量数据,图中的 CL 为中心线UCL/LCL 为控制限。
</div>
</div>
<div class="chart-card wide">
<div class="chart-title">
<span>Xbar-S 控制图</span>
<span>XS</span>
</div>
<div class="chart empty" id="xsChart">等待执行</div>
<div class="chart-note">
<strong>Xbar-S</strong>
用子组均值观察过程中心用样本标准差观察组内离散程度。适合子组容量较大或更关注标准差口径的场景S 图的控制限由 B3/B4 系数计算。
</div>
</div>
<div class="chart-card">
<div class="chart-title">
<span>过程能力指标</span>
<span>Cp / Cpk / Pp / Ppk</span>
</div>
<div class="chart empty" id="capabilityChart">等待执行</div>
<div class="chart-note">
<strong>能力指标</strong>
对比规格限下的短期和整体能力。柱子越高表示能力越强,参考线 1.00 和 1.33 用于快速判断过程是否达到常见能力门槛。
</div>
</div>
<div class="chart-card">
<div class="chart-title">
<span>直方图与正态曲线</span>
<span>Histogram</span>
</div>
<div class="chart empty" id="histogramChart">等待执行</div>
<div class="chart-note">
<strong>直方图</strong>
查看测量值分布、偏态和集中区间。正态曲线按当前计算结果叠加,用来辅助判断数据分布与能力指标是否相互印证。
</div>
</div>
<div class="chart-card wide">
<div class="chart-title">
<span>I-MR 单值-移动极差图</span>
<span>I-MR</span>
</div>
<div class="chart empty" id="imrChart">等待执行</div>
<div class="chart-note">
<strong>I-MR</strong>
直接使用原始单点序列适合没有稳定子组或每次只采一个值的场景。I 图看单值是否越界MR 图看相邻两点变化是否异常。
</div>
</div>
<div class="chart-card wide">
<div class="chart-title">
<span>Run Chart 趋势图</span>
<span>Run</span>
</div>
<div class="chart empty" id="runChart">等待执行</div>
<div class="chart-note">
<strong>Run Chart</strong>
不使用控制限,重点观察连续上升、连续下降、周期性和长时间偏在中位线一侧等趋势。它适合在看控制图前先做快速过程扫描。
</div>
</div>
<div class="chart-card wide">
<div class="chart-title">
<span>Median-R 中位数-极差图</span>
<span>Median-R</span>
</div>
<div class="chart empty" id="medianRChart">等待执行</div>
<div class="chart-note">
<strong>Median-R</strong>
用每个子组的中位数替代均值,对个别异常点更稳健。它可以和 Xbar-R 对照,如果均值图异常而中位数图稳定,通常说明异常点影响较大。
</div>
</div>
<div class="chart-card wide">
<div class="chart-title">
<span>EWMA 指数加权移动平均图</span>
<span>EWMA</span>
</div>
<div class="chart empty" id="ewmaChart">等待执行</div>
<div class="chart-note">
<strong>EWMA</strong>
对历史数据做指数加权,当前页面 lambda 取 0.2。它比普通控制图更容易发现持续的小幅漂移,适合验证过程是否慢慢偏离目标。
</div>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>字段</th>
<th>WASM</th>
<th>前端复算</th>
<th>差值</th>
<th>状态</th>
</tr>
</thead>
<tbody id="compareBody">
<tr>
<td colspan="5">等待执行</td>
</tr>
</tbody>
</table>
</div>
<div class="tabs" role="tablist" aria-label="结果视图">
<button class="tab active" id="responseTab" type="button">
WASM 响应
</button>
<button class="tab" id="localTab" type="button">前端复算</button>
</div>
<pre id="responseOutput">{}</pre>
<pre class="hidden" id="localOutput">{}</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 sampleData = [
1.55, 1.58, 1.61, 1.6, 1.6, 1.58, 1.63, 1.63, 1.62, 1.63,
1.62, 1.63, 1.62, 1.59, 1.58, 1.58, 1.6, 1.61, 1.62, 1.63,
1.58, 1.64, 1.63, 1.62, 1.62, 1.62, 1.62, 1.63, 1.61, 1.57,
1.64, 1.62, 1.61, 1.6, 1.58, 1.57, 1.59, 1.61, 1.62, 1.63,
1.58, 1.61, 1.6, 1.62, 1.63, 1.6, 1.61, 1.64, 1.64, 1.63,
1.58, 1.6, 1.62, 1.63, 1.65, 1.62, 1.58, 1.59, 1.57, 1.58,
1.57, 1.57, 1.58, 1.59, 1.64, 1.61, 1.64, 1.62, 1.6, 1.59,
1.65, 1.62, 1.62, 1.6, 1.58, 1.57, 1.59, 1.57, 1.59, 1.62,
1.56, 1.57, 1.57, 1.61, 1.62, 1.56, 1.58, 1.59, 1.6, 1.62,
1.58, 1.6, 1.6, 1.62, 1.63, 1.58, 1.59, 1.6, 1.63, 1.62,
1.58, 1.59, 1.62, 1.63, 1.64, 1.58, 1.59, 1.62, 1.63, 1.61,
1.58, 1.59, 1.6, 1.61, 1.63, 1.57, 1.59, 1.61, 1.61, 1.62,
1.58, 1.58, 1.6, 1.61, 1.63, 1.62, 1.58, 1.58, 1.58, 1.57,
1.63, 1.59, 1.57, 1.58, 1.57, 1.58, 1.62, 1.61, 1.63, 1.61,
1.58, 1.57, 1.59, 1.6, 1.62, 1.62, 1.6, 1.6, 1.57, 1.57,
];
const coefficients = {
2: { A2: 1.88, A3: 2.659, B3: 0, B4: 3.267, D3: 0, D4: 3.267, L_D1: 1.128 },
3: { A2: 1.023, A3: 1.954, B3: 0, B4: 2.568, D3: 0, D4: 2.574, L_D1: 1.693 },
4: { A2: 0.729, A3: 1.628, B3: 0, B4: 2.266, D3: 0, D4: 2.282, L_D1: 2.059 },
5: { A2: 0.577, A3: 1.427, B3: 0, B4: 2.089, D3: 0, D4: 2.114, L_D1: 2.326 },
6: { A2: 0.483, A3: 1.287, B3: 0.03, B4: 1.97, D3: 0, D4: 2.004, L_D1: 2.534 },
7: { A2: 0.419, A3: 1.182, B3: 0.118, B4: 1.882, D3: 0.076, D4: 1.924, L_D1: 2.704 },
8: { A2: 0.373, A3: 1.099, B3: 0.185, B4: 1.815, D3: 0.136, D4: 1.864, L_D1: 2.847 },
9: { A2: 0.337, A3: 1.032, B3: 0.239, B4: 1.761, D3: 0.184, D4: 1.816, L_D1: 2.97 },
10: { A2: 0.308, A3: 0.975, B3: 0.284, B4: 1.716, D3: 0.223, D4: 1.777, L_D1: 3.078 },
11: { A2: 0.285, A3: 0.927, B3: 0.321, B4: 1.679, D3: 0.256, D4: 1.744, L_D1: 3.173 },
12: { A2: 0.266, A3: 0.886, B3: 0.354, B4: 1.646, D3: 0.283, D4: 1.717, L_D1: 3.258 },
13: { A2: 0.249, A3: 0.85, B3: 0.382, B4: 1.618, D3: 0.307, D4: 1.693, L_D1: 3.336 },
14: { A2: 0.235, A3: 0.817, B3: 0.406, B4: 1.594, D3: 0.328, D4: 1.672, L_D1: 3.407 },
15: { A2: 0.223, A3: 0.789, B3: 0.428, B4: 1.572, D3: 0.347, D4: 1.653, L_D1: 3.472 },
16: { A2: 0.212, A3: 0.763, B3: 0.448, B4: 1.552, D3: 0.363, D4: 1.637, L_D1: 3.532 },
17: { A2: 0.203, A3: 0.739, B3: 0.466, B4: 1.534, D3: 0.378, D4: 1.622, L_D1: 3.588 },
18: { A2: 0.194, A3: 0.718, B3: 0.482, B4: 1.518, D3: 0.391, D4: 1.608, L_D1: 3.64 },
19: { A2: 0.187, A3: 0.698, B3: 0.497, B4: 1.503, D3: 0.403, D4: 1.597, L_D1: 3.689 },
20: { A2: 0.18, A3: 0.68, B3: 0.51, B4: 1.49, D3: 0.415, D4: 1.585, L_D1: 3.735 },
21: { A2: 0.173, A3: 0.663, B3: 0.523, B4: 1.477, D3: 0.425, D4: 1.575, L_D1: 3.778 },
22: { A2: 0.167, A3: 0.647, B3: 0.534, B4: 1.466, D3: 0.434, D4: 1.566, L_D1: 3.819 },
23: { A2: 0.162, A3: 0.633, B3: 0.545, B4: 1.455, D3: 0.443, D4: 1.557, L_D1: 3.858 },
24: { A2: 0.157, A3: 0.619, B3: 0.555, B4: 1.445, D3: 0.451, D4: 1.548, L_D1: 3.895 },
25: { A2: 0.153, A3: 0.606, B3: 0.565, B4: 1.435, D3: 0.459, D4: 1.541, L_D1: 3.931 },
};
const compareFields = [
"XR.CL_X",
"XR.UCL_X",
"XR.LCL_X",
"XR.CL_R",
"XR.UCL_R",
"XR.LCL_R",
"XS.CL_X",
"XS.UCL_X",
"XS.LCL_X",
"XS.CL_S",
"XS.UCL_S",
"XS.LCL_S",
"Cpk.Singma",
"Cpk.SingmaS",
"Cpk.Cp",
"Cpk.CPU",
"Cpk.CPL",
"Cpk.Cpk",
"Cpk.Pp",
"Cpk.PPU",
"Cpk.PPL",
"Cpk.Ppk",
"Cpk.Ca",
"Cpk.CR",
"Cpk.PR",
];
const nInput = document.getElementById("nInput");
const kInput = document.getElementById("kInput");
const uslInput = document.getElementById("uslInput");
const lslInput = document.getElementById("lslInput");
const dataInput = document.getElementById("dataInput");
const inputCount = document.getElementById("inputCount");
const inputMessage = document.getElementById("inputMessage");
const wasmStatus = document.getElementById("wasmStatus");
const verifyStatus = document.getElementById("verifyStatus");
const compareBody = document.getElementById("compareBody");
const responseOutput = document.getElementById("responseOutput");
const localOutput = document.getElementById("localOutput");
const matchedCount = document.getElementById("matchedCount");
const maxDiff = document.getElementById("maxDiff");
const wasmCpk = document.getElementById("wasmCpk");
const localCpk = document.getElementById("localCpk");
const responseTab = document.getElementById("responseTab");
const localTab = document.getElementById("localTab");
const chartElements = {
xr: document.getElementById("xrChart"),
xs: document.getElementById("xsChart"),
capability: document.getElementById("capabilityChart"),
histogram: document.getElementById("histogramChart"),
imr: document.getElementById("imrChart"),
run: document.getElementById("runChart"),
medianR: document.getElementById("medianRChart"),
ewma: document.getElementById("ewmaChart"),
};
let wasmModule = null;
let charts = null;
// 设置提示信息,统一处理错误和成功状态。
function setMessage(text, type = "") {
inputMessage.textContent = text;
inputMessage.className = `message ${type}`.trim();
}
// 按项目返回口径保留三位小数。
function round3(value) {
return Math.round(value * 1000) / 1000;
}
// 计算数组平均值。
function mean(values) {
if (!values.length) return 0;
return values.reduce((sum, value) => sum + value, 0) / values.length;
}
// 计算数组极差。
function range(values) {
if (values.length < 2) return 0;
return Math.max(...values) - Math.min(...values);
}
// 计算样本标准差,分母为 n-1。
function sampleStd(values) {
if (values.length < 2) return 0;
const avg = mean(values);
const variance =
values.reduce((sum, value) => sum + (value - avg) ** 2, 0) /
(values.length - 1);
return Math.sqrt(variance);
}
// 计算总体标准差,分母为 n。
function populationStd(values) {
if (values.length < 2) return 0;
const avg = mean(values);
const variance =
values.reduce((sum, value) => sum + (value - avg) ** 2, 0) /
values.length;
return Math.sqrt(variance);
}
// 懒初始化 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 = {
xr: echarts.init(chartElements.xr),
xs: echarts.init(chartElements.xs),
capability: echarts.init(chartElements.capability),
histogram: echarts.init(chartElements.histogram),
imr: echarts.init(chartElements.imr),
run: echarts.init(chartElements.run),
medianR: echarts.init(chartElements.medianR),
ewma: echarts.init(chartElements.ewma),
};
window.addEventListener("resize", () => {
Object.values(charts).forEach((chart) => chart.resize());
});
return charts;
}
// 从文本框解析测量数据。
function parseDataInput() {
const values = dataInput.value
.split(/[\s,;]+/)
.map((item) => item.trim())
.filter(Boolean)
.map(Number);
if (values.some((value) => Number.isNaN(value))) {
throw new Error("x 中包含无法解析的数值");
}
return values;
}
// 将数据格式化为便于核对的分行文本。
function formatValues(values, groupSize = 5) {
const rows = [];
for (let i = 0; i < values.length; i += groupSize) {
rows.push(values.slice(i, i + groupSize).join(", "));
}
return rows.join("\n");
}
// 读取页面上的 SPC 输入参数。
function readInput() {
const n = Number(nInput.value);
const k = Number(kInput.value);
const usl = Number(uslInput.value);
const lsl = Number(lslInput.value);
const x = parseDataInput();
if (!Number.isInteger(n) || n < 2 || n > 25) {
throw new Error("n 必须是 2 到 25 之间的整数");
}
if (!Number.isInteger(k) || k < 1) {
throw new Error("k 必须是正整数");
}
if (!Number.isFinite(usl) || !Number.isFinite(lsl)) {
throw new Error("USL 和 LSL 必须是有效数字");
}
if (!x.length) {
throw new Error("x 不能为空");
}
return { n, k, usl, lsl, x };
}
// 按 SPC 接口格式组装请求体。
function buildRequest(input) {
return {
msg: "spc verify",
req_code: `SPC_VERIFY_${Date.now()}`,
req_from: "spc_test_page",
req_cmd: "Cmd_Spc",
req_param: input,
};
}
// 将输入切成当前代码口径下使用的子组。
function buildGroups(values, groupSize) {
const count = Math.floor(values.length / groupSize);
return Array.from({ length: count }, (_, index) =>
values.slice(index * groupSize, (index + 1) * groupSize),
);
}
// 使用前端同口径公式复算 SPC 结果。
function calculateLocalSpc(input) {
const coeff = coefficients[input.n];
if (!coeff) {
throw new Error("当前页面未配置该 n 对应的 SPC 系数");
}
const groups = buildGroups(input.x, input.n);
if (!groups.length) {
throw new Error("有效子组数为 0");
}
let usl = input.usl;
let lsl = input.lsl;
const overallMean = mean(input.x);
if (Math.abs(usl - lsl) < 0.000001) {
const sigma = populationStd(input.x);
usl = overallMean + 3 * sigma;
lsl = overallMean - 3 * sigma;
}
const xBars = groups.map(mean);
const ranges = groups.map(range);
const stds = groups.map(sampleStd);
const clX = mean(xBars);
const rBar = mean(ranges);
const sBar = mean(stds);
const sigma = rBar / coeff.L_D1;
const tolerance = usl - lsl;
const center = (usl + lsl) / 2;
const result = {
XR: {
n: input.n,
k: groups.length,
CL_X: clX,
UCL_X: clX + coeff.A2 * rBar,
LCL_X: clX - coeff.A2 * rBar,
CL_R: rBar,
UCL_R: coeff.D4 * rBar,
LCL_R: coeff.D3 * rBar,
CL_Xk: xBars,
CL_Rk: ranges,
},
XS: {
n: input.n,
k: groups.length,
CL_X: clX,
UCL_X: clX + coeff.A3 * rBar,
LCL_X: clX - coeff.A3 * rBar,
CL_S: sBar,
UCL_S: coeff.B4 * sBar,
LCL_S: coeff.B3 * sBar,
CL_Xk: xBars,
CL_Sk: stds,
},
Cpk: {
n: input.n,
k: groups.length,
SL: tolerance > 0 ? center : 0,
USL: usl,
LSL: lsl,
Singma: sigma,
SingmaS: sBar,
Ca: tolerance > 0 ? Math.abs(overallMean - center) / (tolerance / 2) : 0,
Cp: tolerance > 0 ? tolerance / (6 * sigma) : 0,
CPU: tolerance > 0 ? (usl - overallMean) / (3 * sigma) : 0,
CPL: tolerance > 0 ? (overallMean - lsl) / (3 * sigma) : 0,
Cpk: 0,
Pp: tolerance > 0 ? tolerance / (6 * sBar) : 0,
PPU: tolerance > 0 ? (usl - overallMean) / (3 * sBar) : 0,
PPL: tolerance > 0 ? (overallMean - lsl) / (3 * sBar) : 0,
Ppk: 0,
CR: tolerance > 0 ? (6 * sigma) / tolerance : 0,
PR: tolerance > 0 ? (6 * sBar) / tolerance : 0,
},
};
result.Cpk.Cpk = Math.min(result.Cpk.CPU, result.Cpk.CPL);
result.Cpk.Ppk = Math.min(result.Cpk.PPU, result.Cpk.PPL);
return roundSpcResult(result);
}
// 将前端复算结果按 WASM 返回格式整体保留三位小数。
function roundSpcResult(result) {
const clone = JSON.parse(JSON.stringify(result));
const walk = (value) => {
if (Array.isArray(value)) {
return value.map(walk);
}
if (value && typeof value === "object") {
Object.keys(value).forEach((key) => {
value[key] = walk(value[key]);
});
return value;
}
if (typeof value === "number") {
return round3(value);
}
return value;
};
return walk(clone);
}
// 根据点路径读取嵌套对象值。
function getByPath(source, path) {
return path.split(".").reduce((current, key) => {
if (current === undefined || current === null) return undefined;
return current[key];
}, source);
}
// 生成序号数组作为控制图横轴。
function sequenceLabels(length) {
return Array.from({ length }, (_, index) => String(index + 1));
}
// 生成固定值数组,用于绘制中心线和控制限。
function constantSeries(length, value) {
return Array.from({ length }, () => value);
}
// 创建控制图通用配置。
function createControlChartOption(title, xValues, yValues, limits, yName) {
const labels = sequenceLabels(xValues.length);
return {
color: ["#126b5c", "#b45f06", "#8a5a00", "#b42318"],
tooltip: { trigger: "axis" },
legend: {
top: 0,
right: 0,
itemWidth: 12,
itemHeight: 8,
},
grid: [
{ left: 48, right: 24, top: 42, height: 82 },
{ left: 48, right: 24, top: 188, height: 72 },
],
xAxis: [
{
type: "category",
gridIndex: 0,
data: labels,
axisLabel: { color: "#65736e" },
},
{
type: "category",
gridIndex: 1,
data: labels,
axisLabel: { color: "#65736e" },
},
],
yAxis: [
{
type: "value",
name: "Xbar",
gridIndex: 0,
scale: true,
axisLabel: { color: "#65736e" },
splitLine: { lineStyle: { color: "#e5ebe8" } },
},
{
type: "value",
name: yName,
gridIndex: 1,
scale: true,
axisLabel: { color: "#65736e" },
splitLine: { lineStyle: { color: "#e5ebe8" } },
},
],
series: [
{
name: `${title} Xbar`,
type: "line",
data: xValues,
xAxisIndex: 0,
yAxisIndex: 0,
symbolSize: 6,
smooth: false,
},
{
name: "CL_X",
type: "line",
data: constantSeries(xValues.length, limits.CL_X),
xAxisIndex: 0,
yAxisIndex: 0,
symbol: "none",
lineStyle: { type: "dashed", width: 1.5 },
},
{
name: "UCL/LCL_X",
type: "line",
data: constantSeries(xValues.length, limits.UCL_X),
xAxisIndex: 0,
yAxisIndex: 0,
symbol: "none",
lineStyle: { type: "dotted", width: 1.5 },
},
{
name: "UCL/LCL_X",
type: "line",
data: constantSeries(xValues.length, limits.LCL_X),
xAxisIndex: 0,
yAxisIndex: 0,
symbol: "none",
lineStyle: { type: "dotted", width: 1.5 },
},
{
name: yName,
type: "bar",
data: yValues,
xAxisIndex: 1,
yAxisIndex: 1,
barMaxWidth: 18,
},
{
name: `CL_${yName}`,
type: "line",
data: constantSeries(yValues.length, limits[`CL_${yName}`]),
xAxisIndex: 1,
yAxisIndex: 1,
symbol: "none",
lineStyle: { type: "dashed", width: 1.5 },
},
{
name: `UCL/LCL_${yName}`,
type: "line",
data: constantSeries(yValues.length, limits[`UCL_${yName}`]),
xAxisIndex: 1,
yAxisIndex: 1,
symbol: "none",
lineStyle: { type: "dotted", width: 1.5 },
},
{
name: `UCL/LCL_${yName}`,
type: "line",
data: constantSeries(yValues.length, limits[`LCL_${yName}`]),
xAxisIndex: 1,
yAxisIndex: 1,
symbol: "none",
lineStyle: { type: "dotted", width: 1.5 },
},
],
};
}
// 刷新能力指标柱状图。
function createCapabilityOption(cpk) {
const labels = ["Cp", "Cpk", "Pp", "Ppk", "CPU", "CPL"];
return {
color: ["#126b5c"],
tooltip: { trigger: "axis" },
grid: { left: 46, right: 20, top: 24, bottom: 36 },
xAxis: {
type: "category",
data: labels,
axisLabel: { color: "#65736e" },
},
yAxis: {
type: "value",
axisLabel: { color: "#65736e" },
splitLine: { lineStyle: { color: "#e5ebe8" } },
},
series: [
{
name: "能力指标",
type: "bar",
data: labels.map((label) => cpk[label]),
barMaxWidth: 34,
itemStyle: {
color: (params) =>
params.value >= 1.33 ? "#157347" : params.value >= 1 ? "#b45f06" : "#b42318",
},
markLine: {
symbol: "none",
data: [
{ yAxis: 1, name: "1.00" },
{ yAxis: 1.33, name: "1.33" },
],
label: { color: "#65736e" },
lineStyle: { color: "#8a5a00", type: "dashed" },
},
},
],
};
}
// 刷新直方图和正态曲线。
function createHistogramOption(cpk) {
return {
color: ["#126b5c", "#b45f06"],
tooltip: { trigger: "axis" },
legend: { top: 0, right: 0 },
grid: { left: 48, right: 24, top: 42, bottom: 42 },
xAxis: {
type: "value",
name: "X",
axisLabel: { color: "#65736e" },
},
yAxis: [
{
type: "value",
name: "频数",
axisLabel: { color: "#65736e" },
splitLine: { lineStyle: { color: "#e5ebe8" } },
},
{
type: "value",
name: "曲线",
axisLabel: { color: "#65736e" },
splitLine: { show: false },
},
],
series: [
{
name: "频数",
type: "bar",
data: cpk.Xk.map((value, index) => [value, cpk.YkCount[index]]),
barMaxWidth: 32,
},
{
name: "正态曲线",
type: "line",
yAxisIndex: 1,
data: cpk.NormalDistributionX.map((value, index) => [
value,
cpk.NormalDistributionY[index],
]),
smooth: true,
symbolSize: 5,
},
],
};
}
// 计算输入数据的中位数。
function median(values) {
if (!values.length) return 0;
const sorted = [...values].sort((a, b) => a - b);
const middle = Math.floor(sorted.length / 2);
if (sorted.length % 2 === 0) {
return (sorted[middle - 1] + sorted[middle]) / 2;
}
return sorted[middle];
}
// 计算 I-MR 单值和移动极差控制图数据。
function calculateImrData(values) {
const movingRanges = values
.slice(1)
.map((value, index) => Math.abs(value - values[index]));
const center = mean(values);
const mrBar = mean(movingRanges);
const sigma = mrBar / 1.128;
const upper = center + 3 * sigma;
const lower = center - 3 * sigma;
return {
values: values.map(round3),
movingRanges: movingRanges.map(round3),
center: round3(center),
upper: round3(upper),
lower: round3(lower),
mrCenter: round3(mrBar),
mrUpper: round3(3.267 * mrBar),
mrLower: 0,
};
}
// 计算 Median-R 中位数和极差控制图数据。
function calculateMedianRData(groups, subgroupSize) {
const coeff = coefficients[subgroupSize];
const medians = groups.map(median);
const ranges = groups.map(range);
const center = mean(medians);
const rBar = mean(ranges);
return {
medians: medians.map(round3),
ranges: ranges.map(round3),
center: round3(center),
upper: round3(center + coeff.A2 * rBar),
lower: round3(center - coeff.A2 * rBar),
rCenter: round3(rBar),
rUpper: round3(coeff.D4 * rBar),
rLower: round3(coeff.D3 * rBar),
};
}
// 计算 EWMA 控制图数据,默认 lambda 取 0.2。
function calculateEwmaData(values, lambda = 0.2) {
const center = mean(values);
const movingRanges = values
.slice(1)
.map((value, index) => Math.abs(value - values[index]));
const sigma = mean(movingRanges) / 1.128;
const ewma = [];
let previous = center;
values.forEach((value) => {
previous = lambda * value + (1 - lambda) * previous;
ewma.push(previous);
});
const factor = Math.sqrt(lambda / (2 - lambda));
return {
values: ewma.map(round3),
center: round3(center),
upper: round3(center + 3 * sigma * factor),
lower: round3(center - 3 * sigma * factor),
lambda,
};
}
// 创建 I-MR 控制图配置。
function createImrOption(imr) {
const valueLabels = sequenceLabels(imr.values.length);
const mrLabels = sequenceLabels(imr.movingRanges.length);
return {
color: ["#126b5c", "#b45f06", "#8a5a00", "#b42318"],
tooltip: { trigger: "axis" },
legend: { top: 0, right: 0 },
grid: [
{ left: 48, right: 24, top: 42, height: 82 },
{ left: 48, right: 24, top: 188, height: 72 },
],
xAxis: [
{ type: "category", gridIndex: 0, data: valueLabels, axisLabel: { color: "#65736e" } },
{ type: "category", gridIndex: 1, data: mrLabels, axisLabel: { color: "#65736e" } },
],
yAxis: [
{
type: "value",
name: "I",
gridIndex: 0,
scale: true,
axisLabel: { color: "#65736e" },
splitLine: { lineStyle: { color: "#e5ebe8" } },
},
{
type: "value",
name: "MR",
gridIndex: 1,
scale: true,
axisLabel: { color: "#65736e" },
splitLine: { lineStyle: { color: "#e5ebe8" } },
},
],
series: [
{ name: "单值", type: "line", data: imr.values, xAxisIndex: 0, yAxisIndex: 0, symbolSize: 5 },
{ name: "CL_I", type: "line", data: constantSeries(imr.values.length, imr.center), xAxisIndex: 0, yAxisIndex: 0, symbol: "none", lineStyle: { type: "dashed" } },
{ name: "UCL/LCL_I", type: "line", data: constantSeries(imr.values.length, imr.upper), xAxisIndex: 0, yAxisIndex: 0, symbol: "none", lineStyle: { type: "dotted" } },
{ name: "UCL/LCL_I", type: "line", data: constantSeries(imr.values.length, imr.lower), xAxisIndex: 0, yAxisIndex: 0, symbol: "none", lineStyle: { type: "dotted" } },
{ name: "移动极差", type: "bar", data: imr.movingRanges, xAxisIndex: 1, yAxisIndex: 1, barMaxWidth: 14 },
{ name: "CL_MR", type: "line", data: constantSeries(imr.movingRanges.length, imr.mrCenter), xAxisIndex: 1, yAxisIndex: 1, symbol: "none", lineStyle: { type: "dashed" } },
{ name: "UCL_MR", type: "line", data: constantSeries(imr.movingRanges.length, imr.mrUpper), xAxisIndex: 1, yAxisIndex: 1, symbol: "none", lineStyle: { type: "dotted" } },
],
};
}
// 创建 Run Chart 趋势图配置。
function createRunChartOption(values) {
const roundedValues = values.map(round3);
const center = round3(median(values));
return {
color: ["#126b5c", "#b45f06"],
tooltip: { trigger: "axis" },
legend: { top: 0, right: 0 },
grid: { left: 48, right: 24, top: 42, bottom: 38 },
xAxis: {
type: "category",
data: sequenceLabels(values.length),
axisLabel: { color: "#65736e" },
},
yAxis: {
type: "value",
scale: true,
axisLabel: { color: "#65736e" },
splitLine: { lineStyle: { color: "#e5ebe8" } },
},
series: [
{
name: "原始序列",
type: "line",
data: roundedValues,
symbolSize: 4,
},
{
name: "中位线",
type: "line",
data: constantSeries(values.length, center),
symbol: "none",
lineStyle: { type: "dashed" },
},
],
};
}
// 创建 Median-R 控制图配置。
function createMedianROption(medianR) {
return createControlChartOption(
"Median-R",
medianR.medians,
medianR.ranges,
{
CL_X: medianR.center,
UCL_X: medianR.upper,
LCL_X: medianR.lower,
CL_R: medianR.rCenter,
UCL_R: medianR.rUpper,
LCL_R: medianR.rLower,
},
"R",
);
}
// 创建 EWMA 控制图配置。
function createEwmaOption(ewma) {
return {
color: ["#126b5c", "#b45f06", "#b42318"],
tooltip: { trigger: "axis" },
legend: { top: 0, right: 0 },
grid: { left: 48, right: 24, top: 42, bottom: 38 },
xAxis: {
type: "category",
data: sequenceLabels(ewma.values.length),
axisLabel: { color: "#65736e" },
},
yAxis: {
type: "value",
scale: true,
axisLabel: { color: "#65736e" },
splitLine: { lineStyle: { color: "#e5ebe8" } },
},
series: [
{
name: `EWMA λ=${ewma.lambda}`,
type: "line",
data: ewma.values,
smooth: true,
symbolSize: 4,
},
{
name: "CL",
type: "line",
data: constantSeries(ewma.values.length, ewma.center),
symbol: "none",
lineStyle: { type: "dashed" },
},
{
name: "UCL/LCL",
type: "line",
data: constantSeries(ewma.values.length, ewma.upper),
symbol: "none",
lineStyle: { type: "dotted" },
},
{
name: "UCL/LCL",
type: "line",
data: constantSeries(ewma.values.length, ewma.lower),
symbol: "none",
lineStyle: { type: "dotted" },
},
],
};
}
// 使用 WASM 返回结果刷新全部 ECharts 图形。
function renderCharts(spcResult, input) {
const chartMap = ensureCharts();
const groups = buildGroups(input.x, input.n);
const imr = calculateImrData(input.x);
const medianR = calculateMedianRData(groups, input.n);
const ewma = calculateEwmaData(input.x);
chartMap.xr.setOption(
createControlChartOption(
"XR",
spcResult.XR.CL_Xk,
spcResult.XR.CL_Rk,
spcResult.XR,
"R",
),
true,
);
chartMap.xs.setOption(
createControlChartOption(
"XS",
spcResult.XS.CL_Xk,
spcResult.XS.CL_Sk,
spcResult.XS,
"S",
),
true,
);
chartMap.capability.setOption(createCapabilityOption(spcResult.Cpk), true);
chartMap.histogram.setOption(createHistogramOption(spcResult.Cpk), true);
chartMap.imr.setOption(createImrOption(imr), true);
chartMap.run.setOption(createRunChartOption(input.x), true);
chartMap.medianR.setOption(createMedianROption(medianR), true);
chartMap.ewma.setOption(createEwmaOption(ewma), true);
}
// 更新输入数据数量显示。
function updateInputCount() {
try {
const values = parseDataInput();
const n = Number(nInput.value) || 0;
const used = n > 0 ? Math.floor(values.length / n) * n : 0;
inputCount.textContent = `${values.length} 个数据 / 使用 ${used}`;
} catch {
inputCount.textContent = "数据异常";
}
}
// 初始化表单为内置样例。
function loadSample() {
nInput.value = "5";
kInput.value = "30";
uslInput.value = "1.7";
lslInput.value = "1.5";
dataInput.value = formatValues(sampleData, 5);
updateInputCount();
setMessage("样例已填入", "is-ok");
}
// 加载并初始化 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 callSpc(input) {
const module = await ensureWasm();
const payload = JSON.stringify(buildRequest(input));
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 响应和前端复算 JSON。
function renderJsonOutputs(response, localResult) {
responseOutput.textContent = JSON.stringify(response, null, 2);
localOutput.textContent = JSON.stringify(localResult, null, 2);
}
// 渲染核心字段对比表。
function renderComparison(wasmResult, localResult) {
const tolerance = 0.000001;
let okCount = 0;
let largestDiff = 0;
compareBody.innerHTML = "";
compareFields.forEach((field) => {
const wasmValue = Number(getByPath(wasmResult, field));
const localValue = Number(getByPath(localResult, field));
const diff = Math.abs(wasmValue - localValue);
const matched = Number.isFinite(diff) && diff <= tolerance;
if (matched) okCount += 1;
if (Number.isFinite(diff)) largestDiff = Math.max(largestDiff, diff);
const row = document.createElement("tr");
row.innerHTML = `
<td>${field}</td>
<td>${Number.isFinite(wasmValue) ? wasmValue : "-"}</td>
<td>${Number.isFinite(localValue) ? localValue : "-"}</td>
<td>${Number.isFinite(diff) ? diff.toFixed(6) : "-"}</td>
<td><span class="badge ${matched ? "ok" : "warn"}">${matched ? "一致" : "不一致"}</span></td>
`;
compareBody.appendChild(row);
});
matchedCount.textContent = `${okCount}/${compareFields.length}`;
maxDiff.textContent = largestDiff.toFixed(6);
wasmCpk.textContent = getByPath(wasmResult, "Cpk.Cpk") ?? "-";
localCpk.textContent = getByPath(localResult, "Cpk.Cpk") ?? "-";
const allMatched = okCount === compareFields.length;
verifyStatus.textContent = allMatched ? "全部一致" : "存在差异";
verifyStatus.className = `badge ${allMatched ? "ok" : "warn"}`;
matchedCount.className = `metric-value ${allMatched ? "ok" : "warn"}`;
}
// 切换响应和复算详情面板。
function setActiveTab(name) {
const isResponse = name === "response";
responseTab.classList.toggle("active", isResponse);
localTab.classList.toggle("active", !isResponse);
responseOutput.classList.toggle("hidden", !isResponse);
localOutput.classList.toggle("hidden", isResponse);
}
// 执行一次 WASM 调用和前端复算验证。
async function runVerification() {
try {
setMessage("");
verifyStatus.textContent = "执行中";
verifyStatus.className = "badge";
const input = readInput();
const localResult = calculateLocalSpc(input);
const response = await callSpc(input);
if (response.res_data && response.res_data.error) {
throw new Error(response.res_data.error);
}
renderJsonOutputs(response, localResult);
renderComparison(response.res_data, localResult);
renderCharts(response.res_data, input);
setMessage("验证完成", "is-ok");
} catch (error) {
verifyStatus.textContent = "失败";
verifyStatus.className = "badge warn";
setMessage(error.message || String(error), "is-error");
}
}
document.getElementById("sampleButton").addEventListener("click", loadSample);
document.getElementById("formatButton").addEventListener("click", () => {
try {
const values = parseDataInput();
dataInput.value = formatValues(values, Number(nInput.value) || 5);
updateInputCount();
setMessage("数据已格式化", "is-ok");
} catch (error) {
setMessage(error.message || String(error), "is-error");
}
});
document.getElementById("runButton").addEventListener("click", runVerification);
responseTab.addEventListener("click", () => setActiveTab("response"));
localTab.addEventListener("click", () => setActiveTab("local"));
[nInput, kInput, dataInput].forEach((element) => {
element.addEventListener("input", updateInputCount);
});
loadSample();
</script>
</body>
</html>