feat: FASE 5b/6.1+6.2 - SPC Backend + Dashboard Metrologist (Plotly.js)
Aggiunge servizio SPC con calcoli Cp/Cpk/Pp/Ppk, carta di controllo (UCL/LCL), istogramma con curva normale. Router FastAPI con 5 endpoint statistics, blueprint Flask con proxy AJAX, dashboard interattiva Alpine.js + Plotly.js con filtri per ricetta/subtask/date, riepilogo pass/fail, gauge Cpk e i18n IT/EN completo. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+31
-128
@@ -1,142 +1,45 @@
|
||||
/**
|
||||
* Caliper Connection Manager
|
||||
* Web Serial API integration for USB digital calipers
|
||||
* Supports standard SPC/Digimatic protocol
|
||||
* Caliper Status - Passive HID Monitor
|
||||
* USB digital calipers act as keyboard emulators (HID),
|
||||
* sending digits + Enter as keystrokes. This component
|
||||
* monitors numpad-confirm events with inputMethod === 'usb_caliper'
|
||||
* and provides visual feedback (no Web Serial needed).
|
||||
*/
|
||||
|
||||
function caliperConnection() {
|
||||
function caliperStatus() {
|
||||
return {
|
||||
connected: false,
|
||||
status: 'disconnected', // disconnected, connecting, connected, error
|
||||
lastReading: null,
|
||||
port: null,
|
||||
reader: null,
|
||||
readController: null,
|
||||
active: false, // Flash active (for animation)
|
||||
lastReading: null, // Last value read from caliper
|
||||
readingCount: 0, // Session reading counter
|
||||
_flashTimeout: null,
|
||||
_onConfirm: null,
|
||||
|
||||
get isSupported() {
|
||||
return 'serial' in navigator;
|
||||
init() {
|
||||
// Listen for numpad confirmations with usb_caliper method
|
||||
this._onConfirm = (e) => {
|
||||
if (e.detail && e.detail.inputMethod === 'usb_caliper') {
|
||||
this.registerReading(e.detail.value);
|
||||
}
|
||||
};
|
||||
window.addEventListener('numpad-confirm', this._onConfirm);
|
||||
},
|
||||
|
||||
async connect() {
|
||||
if (!this.isSupported) {
|
||||
this.status = 'error';
|
||||
console.error('Web Serial API not supported');
|
||||
return;
|
||||
}
|
||||
registerReading(value) {
|
||||
this.lastReading = value;
|
||||
this.readingCount++;
|
||||
this.active = true;
|
||||
|
||||
try {
|
||||
this.status = 'connecting';
|
||||
|
||||
// Request port selection
|
||||
this.port = await navigator.serial.requestPort();
|
||||
|
||||
// Open port with standard caliper settings
|
||||
await this.port.open({
|
||||
baudRate: 9600,
|
||||
dataBits: 8,
|
||||
stopBits: 1,
|
||||
parity: 'none',
|
||||
flowControl: 'none'
|
||||
});
|
||||
|
||||
this.connected = true;
|
||||
this.status = 'connected';
|
||||
|
||||
// Start reading loop
|
||||
this.readLoop();
|
||||
|
||||
} catch (err) {
|
||||
this.status = 'error';
|
||||
this.connected = false;
|
||||
console.error('Caliper connection error:', err);
|
||||
}
|
||||
clearTimeout(this._flashTimeout);
|
||||
this._flashTimeout = setTimeout(() => {
|
||||
this.active = false;
|
||||
}, 1500);
|
||||
},
|
||||
|
||||
async readLoop() {
|
||||
if (!this.port || !this.port.readable) return;
|
||||
|
||||
const reader = this.port.readable.getReader();
|
||||
this.reader = reader;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) {
|
||||
console.log('Reader closed');
|
||||
break;
|
||||
}
|
||||
this.parseData(value);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.name !== 'NetworkError') {
|
||||
console.error('Read error:', err);
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
},
|
||||
|
||||
parseData(data) {
|
||||
try {
|
||||
// Decode buffer to text
|
||||
const text = new TextDecoder().decode(data);
|
||||
|
||||
// Standard digital caliper protocol patterns
|
||||
// Format: typically " XX.XXX mm\r\n" or similar
|
||||
// Try multiple patterns for compatibility
|
||||
|
||||
// Pattern 1: Standard with units
|
||||
let match = text.match(/([+-]?\s*\d+\.?\d*)\s*mm/i);
|
||||
|
||||
// Pattern 2: Just number with optional sign
|
||||
if (!match) {
|
||||
match = text.match(/([+-]?\s*\d+\.?\d*)/);
|
||||
}
|
||||
|
||||
if (match) {
|
||||
// Clean whitespace and parse
|
||||
const valueStr = match[1].replace(/\s+/g, '');
|
||||
const value = parseFloat(valueStr);
|
||||
|
||||
if (!isNaN(value)) {
|
||||
this.lastReading = value;
|
||||
|
||||
// Dispatch event for other components
|
||||
this.$dispatch('caliper-reading', {
|
||||
value: value,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Parse error:', err);
|
||||
}
|
||||
},
|
||||
|
||||
async disconnect() {
|
||||
try {
|
||||
// Cancel reader
|
||||
if (this.reader) {
|
||||
await this.reader.cancel();
|
||||
this.reader = null;
|
||||
}
|
||||
|
||||
// Close port
|
||||
if (this.port) {
|
||||
await this.port.close();
|
||||
this.port = null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Disconnect error:', err);
|
||||
} finally {
|
||||
this.connected = false;
|
||||
this.status = 'disconnected';
|
||||
}
|
||||
},
|
||||
|
||||
// Cleanup on component destroy
|
||||
destroy() {
|
||||
this.disconnect();
|
||||
if (this._onConfirm) {
|
||||
window.removeEventListener('numpad-confirm', this._onConfirm);
|
||||
}
|
||||
clearTimeout(this._flashTimeout);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,6 +13,10 @@ function numpad() {
|
||||
maxIntDigits: 6, // Maximum integer digits
|
||||
maxDecDigits: 6, // Maximum decimal digits
|
||||
|
||||
// HID burst detection (USB caliper vs manual typing)
|
||||
_lastKeyTime: 0, // Timestamp of last keystroke
|
||||
_burstCount: 0, // Consecutive fast keystrokes
|
||||
|
||||
/**
|
||||
* Get the display value with sign
|
||||
*/
|
||||
@@ -115,11 +119,16 @@ function numpad() {
|
||||
|
||||
const val = this.numericValue;
|
||||
|
||||
// Determine input method: 3+ fast keystrokes = USB caliper burst
|
||||
const inputMethod = this._burstCount >= 3 ? 'usb_caliper' : 'manual';
|
||||
|
||||
// Dispatch custom event for parent component to handle
|
||||
this.$dispatch('numpad-confirm', { value: val });
|
||||
this.$dispatch('numpad-confirm', { value: val, inputMethod: inputMethod });
|
||||
|
||||
// Reset after confirmation
|
||||
this.clearAll();
|
||||
this._burstCount = 0;
|
||||
this._lastKeyTime = 0;
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -157,11 +166,13 @@ function numpad() {
|
||||
// Number keys
|
||||
if (e.key >= '0' && e.key <= '9') {
|
||||
e.preventDefault();
|
||||
this._trackBurst();
|
||||
this.addDigit(e.key);
|
||||
}
|
||||
// Decimal point (both . and ,)
|
||||
else if (e.key === '.' || e.key === ',') {
|
||||
e.preventDefault();
|
||||
this._trackBurst();
|
||||
this.addDecimal();
|
||||
}
|
||||
// Backspace
|
||||
@@ -184,6 +195,24 @@ function numpad() {
|
||||
e.preventDefault();
|
||||
this.toggleSign();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Track keystroke timing for HID burst detection.
|
||||
* USB calipers send digits in rapid succession (<80ms apart).
|
||||
* Human typing is much slower (>100ms between keys).
|
||||
*/
|
||||
_trackBurst() {
|
||||
const now = performance.now();
|
||||
const gap = now - this._lastKeyTime;
|
||||
|
||||
if (this._lastKeyTime > 0 && gap < 80) {
|
||||
this._burstCount++;
|
||||
} else {
|
||||
this._burstCount = 1;
|
||||
}
|
||||
|
||||
this._lastKeyTime = now;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* SPC Charts — Plotly.js helper functions for Metrologist dashboard.
|
||||
*
|
||||
* Requires Plotly.js loaded via CDN before this script.
|
||||
* Requires window.SPC_I18N object set in the template for translations.
|
||||
*
|
||||
* Brand colors:
|
||||
* Primary: #2563EB
|
||||
* Pass: #10b981
|
||||
* Warning: #f59e0b
|
||||
* Fail: #ef4444
|
||||
*/
|
||||
|
||||
// i18n helper — falls back to Italian defaults
|
||||
const _t = (key) => (window.SPC_I18N && window.SPC_I18N[key]) || key;
|
||||
|
||||
const SPC_COLORS = {
|
||||
primary: '#2563EB',
|
||||
primaryLight: '#3B82F6',
|
||||
pass: '#10b981',
|
||||
warning: '#f59e0b',
|
||||
fail: '#ef4444',
|
||||
steel: '#64748B',
|
||||
steelLight: '#94A3B8',
|
||||
nominal: '#8b5cf6',
|
||||
ucl_lcl: '#ef4444',
|
||||
uwl_lwl: '#f59e0b',
|
||||
utl_ltl: '#dc2626',
|
||||
bg: '#ffffff',
|
||||
gridColor: '#e2e8f0',
|
||||
textColor: '#334155',
|
||||
};
|
||||
|
||||
const PLOTLY_LAYOUT_DEFAULTS = {
|
||||
font: { family: 'Inter, system-ui, sans-serif', color: SPC_COLORS.textColor },
|
||||
paper_bgcolor: 'rgba(0,0,0,0)',
|
||||
plot_bgcolor: 'rgba(0,0,0,0)',
|
||||
margin: { t: 40, r: 30, b: 50, l: 60 },
|
||||
xaxis: { gridcolor: SPC_COLORS.gridColor, zeroline: false },
|
||||
yaxis: { gridcolor: SPC_COLORS.gridColor, zeroline: false },
|
||||
};
|
||||
|
||||
const PLOTLY_CONFIG = {
|
||||
responsive: true,
|
||||
displayModeBar: true,
|
||||
modeBarButtonsToRemove: ['lasso2d', 'select2d'],
|
||||
displaylogo: false,
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Render a control chart (X-bar) with tolerance and control limits.
|
||||
*
|
||||
* @param {string} containerId - DOM element ID for the chart.
|
||||
* @param {Object} data - ControlChartData from API.
|
||||
*/
|
||||
function renderControlChart(containerId, data) {
|
||||
if (!data || !data.values || data.values.length === 0) {
|
||||
document.getElementById(containerId).innerHTML =
|
||||
'<p class="text-center text-steel py-12">' + _t('noData') + '</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const n = data.values.length;
|
||||
const xLabels = data.timestamps.map((t, i) => i + 1);
|
||||
|
||||
// Separate in-control and out-of-control points
|
||||
const oocSet = new Set(data.out_of_control || []);
|
||||
const inControlX = [], inControlY = [];
|
||||
const oocX = [], oocY = [];
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (oocSet.has(i)) {
|
||||
oocX.push(xLabels[i]);
|
||||
oocY.push(data.values[i]);
|
||||
} else {
|
||||
inControlX.push(xLabels[i]);
|
||||
inControlY.push(data.values[i]);
|
||||
}
|
||||
}
|
||||
|
||||
const traces = [
|
||||
// In-control points
|
||||
{
|
||||
x: inControlX, y: inControlY,
|
||||
mode: 'markers',
|
||||
type: 'scatter',
|
||||
name: _t('inControl'),
|
||||
marker: { color: SPC_COLORS.primary, size: 6 },
|
||||
},
|
||||
// Out-of-control points
|
||||
{
|
||||
x: oocX, y: oocY,
|
||||
mode: 'markers',
|
||||
type: 'scatter',
|
||||
name: _t('outOfControl'),
|
||||
marker: { color: SPC_COLORS.fail, size: 9, symbol: 'diamond' },
|
||||
},
|
||||
// Line connecting all points
|
||||
{
|
||||
x: xLabels, y: data.values,
|
||||
mode: 'lines',
|
||||
type: 'scatter',
|
||||
name: '',
|
||||
line: { color: SPC_COLORS.steelLight, width: 1 },
|
||||
showlegend: false,
|
||||
},
|
||||
];
|
||||
|
||||
// Helper to add horizontal line
|
||||
const shapes = [];
|
||||
function addLine(yVal, color, dash) {
|
||||
if (yVal == null) return;
|
||||
shapes.push({
|
||||
type: 'line', xref: 'paper', x0: 0, x1: 1, y0: yVal, y1: yVal,
|
||||
line: { color, width: 1.5, dash },
|
||||
});
|
||||
}
|
||||
|
||||
// Mean line
|
||||
addLine(data.mean, SPC_COLORS.primary, 'solid');
|
||||
// UCL / LCL
|
||||
addLine(data.ucl, SPC_COLORS.ucl_lcl, 'dash');
|
||||
addLine(data.lcl, SPC_COLORS.ucl_lcl, 'dash');
|
||||
// UTL / LTL
|
||||
addLine(data.utl, SPC_COLORS.utl_ltl, 'dot');
|
||||
addLine(data.ltl, SPC_COLORS.utl_ltl, 'dot');
|
||||
// UWL / LWL
|
||||
addLine(data.uwl, SPC_COLORS.uwl_lwl, 'dashdot');
|
||||
addLine(data.lwl, SPC_COLORS.uwl_lwl, 'dashdot');
|
||||
// Nominal
|
||||
addLine(data.nominal, SPC_COLORS.nominal, 'longdash');
|
||||
|
||||
// Build annotations for limit labels
|
||||
const annotations = [];
|
||||
function addLabel(yVal, text, color) {
|
||||
if (yVal == null) return;
|
||||
annotations.push({
|
||||
x: 1, xref: 'paper', xanchor: 'left',
|
||||
y: yVal, yanchor: 'middle',
|
||||
text: text,
|
||||
showarrow: false,
|
||||
font: { size: 10, color },
|
||||
});
|
||||
}
|
||||
addLabel(data.mean, 'X\u0304', SPC_COLORS.primary);
|
||||
addLabel(data.ucl, 'UCL', SPC_COLORS.ucl_lcl);
|
||||
addLabel(data.lcl, 'LCL', SPC_COLORS.ucl_lcl);
|
||||
addLabel(data.utl, 'UTL', SPC_COLORS.utl_ltl);
|
||||
addLabel(data.ltl, 'LTL', SPC_COLORS.utl_ltl);
|
||||
addLabel(data.uwl, 'UWL', SPC_COLORS.uwl_lwl);
|
||||
addLabel(data.lwl, 'LWL', SPC_COLORS.uwl_lwl);
|
||||
addLabel(data.nominal, 'NOM', SPC_COLORS.nominal);
|
||||
|
||||
const layout = {
|
||||
...PLOTLY_LAYOUT_DEFAULTS,
|
||||
title: { text: _t('controlChart'), font: { size: 14 } },
|
||||
xaxis: { ...PLOTLY_LAYOUT_DEFAULTS.xaxis, title: _t('measureNum') },
|
||||
yaxis: { ...PLOTLY_LAYOUT_DEFAULTS.yaxis, title: _t('value') },
|
||||
shapes,
|
||||
annotations,
|
||||
showlegend: true,
|
||||
legend: { orientation: 'h', y: -0.15 },
|
||||
margin: { ...PLOTLY_LAYOUT_DEFAULTS.margin, r: 50 },
|
||||
};
|
||||
|
||||
Plotly.newPlot(containerId, traces, layout, PLOTLY_CONFIG);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Render histogram with normal curve overlay.
|
||||
*
|
||||
* @param {string} containerId - DOM element ID.
|
||||
* @param {Object} data - HistogramData from API.
|
||||
* @param {Object} [tol] - Optional tolerance limits {utl, ltl, nominal}.
|
||||
*/
|
||||
function renderHistogram(containerId, data, tol) {
|
||||
if (!data || !data.bins || data.bins.length === 0 || data.n === 0) {
|
||||
document.getElementById(containerId).innerHTML =
|
||||
'<p class="text-center text-steel py-12">' + _t('noData') + '</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Bin centers for bar chart
|
||||
const binCenters = [];
|
||||
const binWidth = data.bins[1] - data.bins[0];
|
||||
for (let i = 0; i < data.counts.length; i++) {
|
||||
binCenters.push((data.bins[i] + data.bins[i + 1]) / 2);
|
||||
}
|
||||
|
||||
const traces = [
|
||||
// Histogram bars
|
||||
{
|
||||
x: binCenters, y: data.counts,
|
||||
type: 'bar',
|
||||
name: _t('frequency'),
|
||||
marker: { color: SPC_COLORS.primaryLight, opacity: 0.7 },
|
||||
width: binWidth * 0.9,
|
||||
},
|
||||
];
|
||||
|
||||
// Normal curve overlay
|
||||
if (data.normal_x && data.normal_x.length > 0) {
|
||||
traces.push({
|
||||
x: data.normal_x, y: data.normal_y,
|
||||
type: 'scatter',
|
||||
mode: 'lines',
|
||||
name: _t('normalCurve'),
|
||||
line: { color: SPC_COLORS.fail, width: 2 },
|
||||
});
|
||||
}
|
||||
|
||||
const shapes = [];
|
||||
function addVertLine(xVal, color, dash) {
|
||||
if (xVal == null) return;
|
||||
shapes.push({
|
||||
type: 'line', yref: 'paper', y0: 0, y1: 1, x0: xVal, x1: xVal,
|
||||
line: { color, width: 1.5, dash },
|
||||
});
|
||||
}
|
||||
|
||||
if (tol) {
|
||||
addVertLine(tol.utl, SPC_COLORS.utl_ltl, 'dot');
|
||||
addVertLine(tol.ltl, SPC_COLORS.utl_ltl, 'dot');
|
||||
addVertLine(tol.nominal, SPC_COLORS.nominal, 'longdash');
|
||||
}
|
||||
|
||||
// Mean line
|
||||
addVertLine(data.mean, SPC_COLORS.primary, 'dash');
|
||||
|
||||
const layout = {
|
||||
...PLOTLY_LAYOUT_DEFAULTS,
|
||||
title: { text: _t('histogram'), font: { size: 14 } },
|
||||
xaxis: { ...PLOTLY_LAYOUT_DEFAULTS.xaxis, title: _t('value') },
|
||||
yaxis: { ...PLOTLY_LAYOUT_DEFAULTS.yaxis, title: _t('frequency') },
|
||||
shapes,
|
||||
bargap: 0.05,
|
||||
showlegend: true,
|
||||
legend: { orientation: 'h', y: -0.15 },
|
||||
};
|
||||
|
||||
Plotly.newPlot(containerId, traces, layout, PLOTLY_CONFIG);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Render a capability gauge (horizontal bar indicator for Cpk).
|
||||
*
|
||||
* @param {string} containerId - DOM element ID.
|
||||
* @param {Object} data - CapabilityData from API.
|
||||
*/
|
||||
function renderCapabilityGauge(containerId, data) {
|
||||
if (!data || data.cpk == null) {
|
||||
document.getElementById(containerId).innerHTML =
|
||||
'<p class="text-center text-steel py-8">' + _t('indicesNotAvailable') + '</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const cpk = data.cpk;
|
||||
|
||||
// Color zones: <1.0 = fail, 1.0-1.33 = warning, >=1.33 = pass
|
||||
let barColor = SPC_COLORS.fail;
|
||||
if (cpk >= 1.33) barColor = SPC_COLORS.pass;
|
||||
else if (cpk >= 1.0) barColor = SPC_COLORS.warning;
|
||||
|
||||
const trace = {
|
||||
type: 'indicator',
|
||||
mode: 'gauge+number',
|
||||
value: cpk,
|
||||
title: { text: 'Cpk', font: { size: 14 } },
|
||||
number: { font: { family: 'JetBrains Mono, monospace', size: 28 } },
|
||||
gauge: {
|
||||
axis: { range: [0, 2.5], tickwidth: 1 },
|
||||
bar: { color: barColor, thickness: 0.6 },
|
||||
bgcolor: '#f1f5f9',
|
||||
borderwidth: 0,
|
||||
steps: [
|
||||
{ range: [0, 1.0], color: '#fecaca' },
|
||||
{ range: [1.0, 1.33], color: '#fef3c7' },
|
||||
{ range: [1.33, 2.5], color: '#d1fae5' },
|
||||
],
|
||||
threshold: {
|
||||
line: { color: SPC_COLORS.primary, width: 3 },
|
||||
thickness: 0.75,
|
||||
value: cpk,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const layout = {
|
||||
...PLOTLY_LAYOUT_DEFAULTS,
|
||||
height: 200,
|
||||
margin: { t: 30, r: 20, b: 10, l: 20 },
|
||||
};
|
||||
|
||||
Plotly.newPlot(containerId, [trace], layout, { ...PLOTLY_CONFIG, displayModeBar: false });
|
||||
}
|
||||
Reference in New Issue
Block a user