FastAPI app that parses CMM inspection reports (PDF/Excel/CSV), computes SPC metrics (Cp/Cpk/Pp/Ppk, control limits, Shapiro-Wilk), generates interactive Plotly charts, and provides AI-powered quality summaries via Azure OpenAI with graceful fallback. Includes 21 passing tests covering parsers, SPC calculations, and API endpoints.
205 lines
7.7 KiB
JavaScript
205 lines
7.7 KiB
JavaScript
(() => {
|
|
const dropZone = document.getElementById("drop-zone");
|
|
const fileInput = document.getElementById("file-input");
|
|
const fileList = document.getElementById("file-list");
|
|
const uploadBtn = document.getElementById("upload-btn");
|
|
const uploadSection = document.getElementById("upload-section");
|
|
const statusSection = document.getElementById("status-section");
|
|
const statusText = document.getElementById("status-text");
|
|
const resultsSection = document.getElementById("results-section");
|
|
const resultsContainer = document.getElementById("results-container");
|
|
|
|
let selectedFiles = [];
|
|
|
|
// Drag & drop
|
|
dropZone.addEventListener("click", () => fileInput.click());
|
|
dropZone.addEventListener("dragover", (e) => { e.preventDefault(); dropZone.classList.add("dragover"); });
|
|
dropZone.addEventListener("dragleave", () => dropZone.classList.remove("dragover"));
|
|
dropZone.addEventListener("drop", (e) => {
|
|
e.preventDefault();
|
|
dropZone.classList.remove("dragover");
|
|
addFiles(e.dataTransfer.files);
|
|
});
|
|
fileInput.addEventListener("change", () => addFiles(fileInput.files));
|
|
|
|
function addFiles(files) {
|
|
for (const f of files) {
|
|
if (!selectedFiles.some((s) => s.name === f.name && s.size === f.size)) {
|
|
selectedFiles.push(f);
|
|
}
|
|
}
|
|
renderFileList();
|
|
}
|
|
|
|
function renderFileList() {
|
|
fileList.innerHTML = "";
|
|
selectedFiles.forEach((f, i) => {
|
|
const tag = document.createElement("span");
|
|
tag.className = "file-tag";
|
|
tag.innerHTML = `${f.name} <span class="remove" data-idx="${i}">×</span>`;
|
|
fileList.appendChild(tag);
|
|
});
|
|
fileList.querySelectorAll(".remove").forEach((btn) =>
|
|
btn.addEventListener("click", (e) => {
|
|
selectedFiles.splice(+e.target.dataset.idx, 1);
|
|
renderFileList();
|
|
})
|
|
);
|
|
uploadBtn.disabled = selectedFiles.length === 0;
|
|
}
|
|
|
|
// Upload
|
|
uploadBtn.addEventListener("click", async () => {
|
|
if (!selectedFiles.length) return;
|
|
|
|
uploadSection.hidden = true;
|
|
statusSection.hidden = false;
|
|
resultsSection.hidden = true;
|
|
statusText.textContent = `Uploading ${selectedFiles.length} file(s)...`;
|
|
|
|
const form = new FormData();
|
|
selectedFiles.forEach((f) => form.append("files", f));
|
|
|
|
try {
|
|
const resp = await fetch("/api/upload", { method: "POST", body: form });
|
|
if (!resp.ok) {
|
|
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
|
|
throw new Error(err.detail || "Upload failed");
|
|
}
|
|
const { batch_id } = await resp.json();
|
|
statusText.textContent = "Analyzing...";
|
|
await pollResults(batch_id);
|
|
} catch (err) {
|
|
statusSection.hidden = true;
|
|
uploadSection.hidden = false;
|
|
alert("Error: " + err.message);
|
|
}
|
|
});
|
|
|
|
async function pollResults(batchId) {
|
|
const maxAttempts = 60;
|
|
for (let i = 0; i < maxAttempts; i++) {
|
|
const resp = await fetch(`/api/results/${batchId}`);
|
|
if (!resp.ok) { await sleep(1000); continue; }
|
|
const data = await resp.json();
|
|
if (data.status === "complete") {
|
|
renderResults(data);
|
|
return;
|
|
}
|
|
statusText.textContent = `Analyzing... (${i + 1}s)`;
|
|
await sleep(1000);
|
|
}
|
|
statusSection.hidden = true;
|
|
uploadSection.hidden = false;
|
|
alert("Timed out waiting for results");
|
|
}
|
|
|
|
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
|
|
|
// Render
|
|
function renderResults(data) {
|
|
statusSection.hidden = true;
|
|
resultsSection.hidden = false;
|
|
resultsContainer.innerHTML = "";
|
|
|
|
for (const file of data.files) {
|
|
const div = document.createElement("div");
|
|
div.className = "file-result";
|
|
|
|
if (file.error) {
|
|
div.innerHTML = `<h2>${esc(file.filename)}</h2><p class="error">Error: ${esc(file.error)}</p>`;
|
|
resultsContainer.appendChild(div);
|
|
continue;
|
|
}
|
|
|
|
let html = `<h2>${esc(file.filename)}</h2>`;
|
|
|
|
// AI Summary
|
|
html += `<div class="summary">${esc(file.summary)}</div>`;
|
|
|
|
// SPC table
|
|
if (file.spc.length) {
|
|
html += `<table class="spc-table"><thead><tr>
|
|
<th>Feature</th><th>n</th><th>Mean</th><th>Std</th>
|
|
<th>Cp</th><th>Cpk</th><th>Pp</th><th>Ppk</th>
|
|
<th>USL</th><th>LSL</th><th>OOS</th>
|
|
</tr></thead><tbody>`;
|
|
for (const s of file.spc) {
|
|
const cpkClass = s.cpk === null ? "" : s.cpk >= 1.33 ? "cpk-good" : s.cpk >= 1.0 ? "cpk-warn" : "cpk-bad";
|
|
html += `<tr>
|
|
<td>${esc(s.feature_name)}</td><td>${s.n}</td>
|
|
<td>${s.mean}</td><td>${s.std}</td>
|
|
<td>${fmtIdx(s.cp)}</td><td class="${cpkClass}">${fmtIdx(s.cpk)}</td>
|
|
<td>${fmtIdx(s.pp)}</td><td>${fmtIdx(s.ppk)}</td>
|
|
<td>${s.usl}</td><td>${s.lsl}</td><td>${s.out_of_spec_count}</td>
|
|
</tr>`;
|
|
}
|
|
html += `</tbody></table>`;
|
|
}
|
|
|
|
// Charts
|
|
html += `<div class="charts-grid">`;
|
|
const histDivs = (file.charts.histograms || []).map((_, i) => `hist-${data.batch_id}-${file.filename}-${i}`);
|
|
const ctrlDivs = (file.charts.control_charts || []).map((_, i) => `ctrl-${data.batch_id}-${file.filename}-${i}`);
|
|
const capDiv = file.charts.capability_bar ? `cap-${data.batch_id}-${file.filename}` : null;
|
|
|
|
histDivs.forEach((id) => { html += `<div class="chart-container" id="${id}"></div>`; });
|
|
ctrlDivs.forEach((id) => { html += `<div class="chart-container" id="${id}"></div>`; });
|
|
if (capDiv) html += `<div class="chart-container chart-full" id="${capDiv}"></div>`;
|
|
html += `</div>`;
|
|
|
|
// Measurements toggle
|
|
if (file.report.measurements && file.report.measurements.length) {
|
|
const tableId = `meas-${data.batch_id}-${file.filename}`;
|
|
html += `<button class="meas-toggle" onclick="document.getElementById('${tableId}').hidden = !document.getElementById('${tableId}').hidden">
|
|
Show/Hide Measurements (${file.report.measurements.length})
|
|
</button>`;
|
|
html += `<div id="${tableId}" hidden><table class="meas-table"><thead><tr>
|
|
<th>Feature</th><th>Nominal</th><th>Actual</th><th>Dev</th>
|
|
<th>Tol+</th><th>Tol-</th><th>USL</th><th>LSL</th><th>Status</th>
|
|
</tr></thead><tbody>`;
|
|
for (const m of file.report.measurements) {
|
|
const cls = m.in_tolerance ? "" : "oot";
|
|
html += `<tr class="${cls}">
|
|
<td>${esc(m.feature_name)}</td><td>${m.nominal}</td><td>${m.actual}</td>
|
|
<td>${m.deviation.toFixed(4)}</td><td>+${m.tolerance_plus}</td><td>${m.tolerance_minus}</td>
|
|
<td>${m.usl}</td><td>${m.lsl}</td>
|
|
<td>${m.in_tolerance ? "OK" : "OOT"}</td>
|
|
</tr>`;
|
|
}
|
|
html += `</tbody></table></div>`;
|
|
}
|
|
|
|
div.innerHTML = html;
|
|
resultsContainer.appendChild(div);
|
|
|
|
// Render Plotly charts after DOM insertion
|
|
requestAnimationFrame(() => {
|
|
(file.charts.histograms || []).forEach((chart, i) => {
|
|
Plotly.newPlot(histDivs[i], chart.data, { ...chart.layout, autosize: true }, { responsive: true });
|
|
});
|
|
(file.charts.control_charts || []).forEach((chart, i) => {
|
|
Plotly.newPlot(ctrlDivs[i], chart.data, { ...chart.layout, autosize: true }, { responsive: true });
|
|
});
|
|
if (capDiv && file.charts.capability_bar) {
|
|
const cap = file.charts.capability_bar;
|
|
Plotly.newPlot(capDiv, cap.data, { ...cap.layout, autosize: true }, { responsive: true });
|
|
}
|
|
});
|
|
}
|
|
|
|
// Reset for new uploads
|
|
selectedFiles = [];
|
|
renderFileList();
|
|
uploadSection.hidden = false;
|
|
}
|
|
|
|
function esc(s) {
|
|
const d = document.createElement("div");
|
|
d.textContent = s || "";
|
|
return d.innerHTML;
|
|
}
|
|
|
|
function fmtIdx(v) { return v === null || v === undefined ? "N/A" : v.toFixed(3); }
|
|
})();
|