1638 lines
51 KiB
HTML
1638 lines
51 KiB
HTML
<!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.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>
|
||
<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>
|
||
<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>
|
||
<div class="chart-card">
|
||
<div class="chart-title">
|
||
<span>直方图与正态曲线</span>
|
||
<span>Histogram</span>
|
||
</div>
|
||
<div class="chart empty" id="histogramChart">等待执行</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>
|
||
<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>
|
||
<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>
|
||
<div class="chart-card wide">
|
||
<div class="chart-title">
|
||
<span>EWMA 指数加权移动平均图</span>
|
||
<span>EWMA</span>
|
||
</div>
|
||
<div class="chart empty" id="ewmaChart">等待执行</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>
|