Operator screen
Reports
Alarm performance, shift, batch and SPC reports, printable and signed.
index.htm as it is written with the smart-industrial package; industrial-demos.css is the screens' shared layout
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
<head>
<title>Reports - Smart.Industrial</title>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" type="text/css" href="node_modules/smart-industrial/source/styles/smart.default.css" />
<link rel="stylesheet" type="text/css" href="node_modules/smart-industrial/source/styles/smart.industrial-elements.css" />
<link rel="stylesheet" type="text/css" href="node_modules/smart-industrial/source/styles/smart.industrial.css" />
<link rel="stylesheet" type="text/css" href="node_modules/smart-industrial/source/styles/smart.industrial-dark.css" />
<link rel="stylesheet" type="text/css" href="industrial-demos.css" />
<link rel="stylesheet" type="text/css" href="styles.css" />
</head>
<body class="viewport" theme="industrial-dark">
<div class="hmi-screen rp-screen">
<header class="hmi-header rp-chrome">
<div class="hmi-identity">
<span class="hmi-plant">REPORTS</span>
<span class="hmi-area">Area 2 · Utilities & Process</span>
</div>
<div class="hmi-header-actions">
<smart-selector-switch id="report" label=""></smart-selector-switch>
<label class="rp-range"><span>Period</span>
<select id="period">
<option value="shift">This shift</option>
<option value="day" selected>Last 24 h</option>
<option value="week">Last 7 days</option>
</select>
</label>
<button class="hmi-button" id="exportCsv" type="button">Export CSV</button>
<button class="hmi-button" id="print" type="button">Print</button>
<button class="hmi-button hmi-button-primary" id="signReport" type="button">Sign report</button>
<button class="hmi-theme-toggle" id="themeToggle" type="button">Light</button>
</div>
</header>
<article class="hmi-panel rp-report" id="reportBody" aria-live="polite">
<header class="rp-title">
<div>
<h1 id="reportTitle">Alarm performance report</h1>
<p class="rp-subtitle" id="reportSubtitle">Area 2 · last 24 h</p>
</div>
<div class="rp-meta">
<span id="reportGenerated">Generated —</span>
<span id="reportHash" class="rp-hash">—</span>
</div>
</header>
<section class="rp-tiles" id="tiles"></section>
<section class="rp-section" id="sectionCharts">
<div class="rp-chart-block">
<h2 id="chartTitle">Alarm rate per hour</h2>
<smart-chart id="chart"></smart-chart>
</div>
<div class="rp-chart-block">
<h2 id="chart2Title">Top 10 bad actors</h2>
<smart-chart id="chart2"></smart-chart>
</div>
</section>
<section class="rp-section" id="sectionSpc" hidden>
<h2>Fill weight, X̄-R</h2>
<smart-control-chart id="spc" type="xbar-r" sample-size="5" baseline-count="20" unit="g" precision-digits="2" show-capability show-stats show-violations label="Fill weight"></smart-control-chart>
</section>
<section class="rp-section">
<h2 id="tableTitle">Detail</h2>
<div class="hmi-scroll-x">
<table class="hmi-table rp-table" id="table"></table>
</div>
</section>
<section class="rp-section" id="sectionRecord">
<h2>Changes in the period</h2>
<smart-audit-trail id="changes" label="Changes in the period" max-rows="50"></smart-audit-trail>
</section>
<footer class="rp-signatures" id="signatures">
<h2>Signatures</h2>
<div class="rp-signature-list" id="signatureList">
<smart-signature-block id="unsigned"></smart-signature-block>
</div>
</footer>
</article>
<smart-esignature id="sign" label="Sign this report" require-reason auto-close="1500"></smart-esignature>
</div>
<!-- scripts -->
<script type="module" src="node_modules/smart-industrial/source/smart.industrial.js"></script>
<script type="module" src="node_modules/smart-industrial/source/modules/smart.industrial.audit.js"></script>
<script type="module" src="node_modules/smart-industrial/source/modules/smart.selectorswitch.js"></script>
<script type="module" src="node_modules/smart-industrial/source/modules/smart.statustile.js"></script>
<script type="module" src="node_modules/smart-webcomponents/source/modules/smart.chart.js"></script>
<script type="module" src="node_modules/smart-industrial/source/modules/smart.controlchart.js"></script>
<script type="module" src="node_modules/smart-industrial/source/modules/smart.audittrail.js"></script>
<script type="module" src="node_modules/smart-industrial/source/modules/smart.esignature.js"></script>
<script type="module" src="node_modules/smart-industrial/source/modules/smart.signatureblock.js"></script>
<script type="module" src="index.js"></script>
<script>
/* Shown once every element has rendered, so nothing moves on the way in (styles/demos.css). */
addEventListener('load', function () {
var all = Array.prototype.slice.call(document.querySelectorAll('*')),
ready = function () { document.body.classList.add('ready'); };
Promise.all(all.map(function (e) { return e.localName.indexOf('smart-') === 0 ? customElements.whenDefined(e.localName) : null; })).then(function () {
return Promise.all(all.filter(function (e) { return typeof e.whenRendered === 'function' && !e.isRendered; }).map(function (e) { return e.whenRendered(); }));
}).then(function () { requestAnimationFrame(function () { requestAnimationFrame(ready); }); }, ready);
});
</script>
<script>
/* The site's light/dark switch is the reader's choice: a screen opened from the light site
in a new tab opens light too. Framed, the site's page sets the theme itself. */
addEventListener('load', function () {
var light = false,
toggle = document.querySelector('.hmi-theme-toggle');
try { light = window.top === window && localStorage.getItem('smart-industrial-dark') === 'false'; } catch (e) { }
/* after the screen's own onload, which is where its button starts listening */
if (light && toggle) { setTimeout(function () { if (document.body.getAttribute('theme') === 'industrial-dark') { toggle.click(); } }, 0); }
});
</script>
</body>
</html>
index.js
/// <reference path="../../../source/typescript/smart.elements.d.ts" />
/* Reports.
*
* Four reports a control room actually produces: the ISA-18.2 alarm performance report
* (rate, floods, standing and stale alarms, the bad actors, the priority mix against the
* recommended 80/15/5), the shift report, the batch report, and the SPC report. Each is
* built from data, rendered as a page that prints, exported as CSV, and signed - the
* signature carrying the hash of the report's data, so a signed report is that report.
*
* The data is generated from a seeded source so a report is the same report on reload;
* every number would come from the alarm historian, the MES and the batch executive. */
window.onload = function () {
const Industrial = window.Smart.Industrial,
Audit = Industrial.Audit,
el = function (id) {
return document.getElementById(id);
},
picker = el('report'),
chart = el('chart'),
chart2 = el('chart2'),
sign = el('sign');
/* ── A seeded source, so the report is reproducible ─────────────────── */
function seeded(seed) {
let state = seed;
return function () {
state = (state * 1664525 + 1013904223) % 4294967296;
return state / 4294967296;
};
}
const random = seeded(20260918);
/* ── The alarm history ──────────────────────────────────────────────── */
const TAGS = [['PT-330', 'Transmitter communication lost', 2, 'Process'], ['LT-2202', 'Tank TK-02 level low', 2, 'Utilities'],
['CV-118', 'Control valve deviation', 3, 'Process'], ['TAH-204', 'Jacket temperature high', 1, 'Process'],
['FT-090', 'Flow transmitter under calibration', 3, 'Utilities'], ['PAH-102', 'Header pressure high', 1, 'Utilities'],
['LAHH-2100', 'Feed tank level high-high', 1, 'Utilities'], ['XA-301', 'Centrifuge vibration', 2, 'Process'],
['TI-410', 'Bearing temperature', 3, 'Process'], ['FAL-101', 'Feed flow low', 2, 'Process'], ['HS-201', 'E-stop pressed', 1, 'Process'],
['AAH-2130', 'Product pH outside band', 3, 'Process']];
const hours = 24,
alarms = [];
for (let h = 0; h < hours; h++) {
/* A flood at 03:00 and a chattering transmitter all day. */
const base = h === 3 ? 34 : 3 + Math.floor(random() * 5);
for (let i = 0; i < base; i++) {
const pick = h === 3 ? TAGS[Math.floor(random() * 6)] : (random() < 0.4 ? TAGS[0] : TAGS[Math.floor(random() * TAGS.length)]),
/* The flood lands inside ten minutes, which is what makes it one. */
raised = new Date(Date.now() - (hours - h) * 3600000 + Math.floor(random() * (h === 3 ? 600000 : 3600000))),
duration = pick[0] === 'PT-330' ? 20 + random() * 60 : 60 + random() * 3600 * (random() < 0.08 ? 12 : 1);
alarms.push({ tag: pick[0], message: pick[1], priority: pick[2], area: pick[3], raised: raised, cleared: new Date(raised.getTime() + duration * 1000),
acknowledgedAfter: Math.round(5 + random() * (pick[2] === 1 ? 60 : 900)), standing: duration > 3600 * 8 });
}
}
alarms.sort(function (a, b) {
return a.raised - b.raised;
});
/* ── Alarm KPIs, ISA-18.2 ───────────────────────────────────────────── */
function alarmKpis() {
const perHour = [],
byTag = {},
byPriority = { 1: 0, 2: 0, 3: 0 };
let floodMinutes = 0;
for (let h = 0; h < hours; h++) {
perHour.push({ hour: ('0' + ((new Date().getHours() - hours + h + 24 + 1) % 24)).slice(-2) + ':00', count: 0 });
}
alarms.forEach(function (alarm) {
const h = Math.min(hours - 1, Math.max(0, Math.floor((alarm.raised.getTime() - (Date.now() - hours * 3600000)) / 3600000)));
perHour[h].count++;
byTag[alarm.tag] = (byTag[alarm.tag] || 0) + 1;
byPriority[alarm.priority]++;
});
/* Ten-minute windows above ten alarms are flood time. */
for (let w = 0; w < hours * 6; w++) {
const start = Date.now() - hours * 3600000 + w * 600000,
count = alarms.filter(function (alarm) {
return alarm.raised.getTime() >= start && alarm.raised.getTime() < start + 600000;
}).length;
if (count > 10) {
floodMinutes += 10;
}
}
const total = alarms.length,
badActors = Object.keys(byTag).map(function (tag) {
return { tag: tag, count: byTag[tag], share: byTag[tag] / total * 100 };
}).sort(function (a, b) {
return b.count - a.count;
}).slice(0, 10),
standing = alarms.filter(function (alarm) {
return alarm.standing;
}),
averageAck = alarms.reduce(function (sum, alarm) {
return sum + alarm.acknowledgedAfter;
}, 0) / total;
return {
total: total,
perTenMinutes: total / (hours * 6),
peakPerTenMinutes: Math.max.apply(null, perHour.map(function (row) {
return row.count;
})) / 6,
floodPercent: floodMinutes / (hours * 60) * 100,
standing: standing.length,
averageAck: averageAck,
priorityMix: [byPriority[1] / total * 100, byPriority[2] / total * 100, byPriority[3] / total * 100],
perHour: perHour,
badActors: badActors,
topTenShare: badActors.reduce(function (sum, actor) {
return sum + actor.count;
}, 0) / total * 100
};
}
/* ── Other reports' data ────────────────────────────────────────────── */
const shift = {
produced: 10684, target: 10800, oee: 84.1, availability: 91.2, performance: 96.8, quality: 99.34,
downtime: [['Label reel change', 14], ['Starved (upstream)', 13], ['Capper jam', 9], ['Filler CIP overrun', 6], ['Cartoner fault', 4]],
hourly: [1310, 1402, 1388, 1355, 1290, 1420, 1211, 1308]
};
const batch = {
id: 'B-2026-0912', recipe: 'IBU-400-C v13', product: 'Ibuprofen 400 mg granulate', size: 2400, yield: 98.4,
phases: [['Charge A', '06:31', '06:52', 'Complete'], ['Charge B', '06:52', '07:10', 'Complete'], ['Heat', '07:10', '07:58', 'Complete · deviation'],
['React', '07:58', '09:40', 'Complete'], ['Sample', '09:40', '09:55', 'Complete · lab pass'], ['Cool', '09:55', '10:31', 'Complete'], ['Discharge', '10:31', '10:49', 'Complete']],
deviations: [['DEV-0331', 'Heat phase 18 min over plan, TIC-201 slow to reach 85 °C', 'A. Smith', 'closed - jacket valve serviced']]
};
const fills = [];
for (let i = 0; i < 48; i++) {
const group = [];
for (let k = 0; k < 5; k++) {
let u = 0;
for (let n = 0; n < 12; n++) {
u += random();
}
group.push(Number((10 + (i > 36 ? (i - 36) * 0.004 : 0) + (u - 6) * 0.03).toFixed(3)));
}
fills.push(group);
}
/* The change record the period covers. */
const trail = new Audit.Trail({ system: 'Area 2 HMI', clock: (function () {
let t = Date.now() - 23 * 3600000;
return function () {
t += 2.6 * 3600000;
return new Date(t);
};
})() });
[['jmoreau', 'J. Moreau', 'update', 'TIC-204 setpoint', 63, 65, 'recipe step 3'], ['asmith', 'A. Smith', 'shelve', 'PAH-102', 'active', 'shelved 30 min', 'CIP transient'],
['jmoreau', 'J. Moreau', 'update', 'FIC-101 gain', 1.8, 1.08, 'oscillation, FCV-101 stiction suspected'], ['asmith', 'A. Smith', 'update', 'Shelve duration', 60, 30, 'ISA-18.2 review'],
['jmoreau', 'J. Moreau', 'acknowledge', 'LAHH-2100', 'unacknowledged', 'acknowledged', ''], ['rkim', 'R. Kim', 'update', 'LIC-301 setpoint', 62, 60, 'level trending high'],
['asmith', 'A. Smith', 'review', 'Area 2 audit trail', '#1', '#41', 'shift review'], ['jmoreau', 'J. Moreau', 'update', 'TIC-201 setpoint', 85, 30, 'recipe step 6']]
.forEach(function (row) {
trail.append({ user: row[0], userName: row[1], action: row[2], record: 'Area 2', field: row[3], oldValue: row[4], newValue: row[5], reason: row[6] });
});
el('changes').entries = trail.entries();
el('changes').showToolbar = false;
el('changes').showIntegrity = false;
/* ── Rendering ──────────────────────────────────────────────────────── */
const palette = Industrial.paletteFor(document.body);
function chartBase(target) {
Object.assign(target, {
animation: 'none', caption: '', description: '', showLegend: false, showBorderLine: false, backgroundColor: 'transparent',
padding: { left: 5, top: 10, right: 10, bottom: 5 }, titlePadding: { left: 0, top: 0, right: 0, bottom: 0 }
});
}
chartBase(chart);
chartBase(chart2);
function tile(label, value, unit, state, footnote) {
const node = document.createElement('smart-status-tile');
node.label = label;
node.value = value;
node.unit = unit || '';
node.state = state || 'neutral';
node.precisionDigits = typeof value === 'number' && value % 1 !== 0 ? 1 : 0;
node.footnote = footnote || '';
el('tiles').appendChild(node);
}
function tableOf(headers, rows) {
const table = el('table');
table.innerHTML = '<thead><tr>' + headers.map(function (header) {
return '<th scope="col">' + header + '</th>';
}).join('') + '</tr></thead><tbody>' + rows.map(function (row) {
return '<tr>' + row.map(function (cell, index) {
return '<td class="' + (typeof cell === 'number' ? 'hmi-num' : '') + '">' + (index === 0 ? '<b>' + cell + '</b>' : cell) + '</td>';
}).join('') + '</tr>';
}).join('') + '</tbody>';
}
let current = { kind: 'alarms', data: null, csv: '' };
function render(kind) {
const periodText = el('period').options[el('period').selectedIndex].text.toLowerCase();
el('tiles').innerHTML = '';
el('sectionSpc').hidden = kind !== 'spc';
el('sectionCharts').hidden = kind === 'spc';
el('sectionRecord').hidden = kind === 'spc';
current.kind = kind;
if (kind === 'alarms') {
const kpi = alarmKpis();
el('reportTitle').textContent = 'Alarm performance report';
el('reportSubtitle').textContent = 'Area 2 · ' + periodText + ' · ISA-18.2 §16 metrics';
tile('Alarms', kpi.total, '', 'neutral', periodText);
tile('Average rate', Number(kpi.perTenMinutes.toFixed(1)), '/ 10 min', kpi.perTenMinutes <= 1 ? 'ok' : kpi.perTenMinutes <= 2 ? 'warning' : 'critical', 'target ≤ 1, max manageable 2');
tile('Peak rate', Number(kpi.peakPerTenMinutes.toFixed(1)), '/ 10 min', kpi.peakPerTenMinutes <= 10 ? 'ok' : 'critical', 'flood above 10');
tile('Time in flood', Number(kpi.floodPercent.toFixed(1)), '%', kpi.floodPercent < 1 ? 'ok' : 'warning', 'target < 1 %');
tile('Standing alarms', kpi.standing, '', kpi.standing <= 5 ? 'ok' : 'warning', 'active > 8 h');
tile('Top 10 share', Number(kpi.topTenShare.toFixed(0)), '%', kpi.topTenShare < 20 ? 'ok' : 'warning', 'of all alarms; target < 20 %');
tile('Priority mix', kpi.priorityMix.map(function (share) {
return Math.round(share);
}).join(' / '), '', 'neutral', 'P1 / P2 / P3 %, recommended 5 / 15 / 80');
tile('Time to acknowledge', Math.round(kpi.averageAck), 's', kpi.averageAck < 300 ? 'ok' : 'warning', 'average');
el('chartTitle').textContent = 'Alarms per hour';
chart.dataSource = kpi.perHour;
chart.xAxis = { dataField: 'hour', gridLines: { visible: false }, unitInterval: 3 };
chart.seriesGroups = [{ type: 'column', columnsGapPercent: 30, valueAxis: { minValue: 0, unitInterval: 10, gridLines: { visible: true, dashStyle: '2,2' } },
series: [{ dataField: 'count', displayText: 'Alarms', fillColor: palette.series[0], lineColor: palette.series[0] }] }];
chart.update();
el('chart2Title').textContent = 'Top 10 bad actors';
chart2.dataSource = kpi.badActors;
chart2.xAxis = { dataField: 'tag', gridLines: { visible: false } };
chart2.seriesGroups = [{ type: 'column', columnsGapPercent: 40, valueAxis: { minValue: 0, unitInterval: 10, gridLines: { visible: true, dashStyle: '2,2' } },
series: [{ dataField: 'count', displayText: 'Alarms', fillColor: palette.series[2], lineColor: palette.series[2] }] }];
chart2.update();
el('tableTitle').textContent = 'Bad actors';
tableOf(['Tag', 'Message', 'Priority', 'Count', 'Share %', 'Recommendation'], kpi.badActors.map(function (actor) {
const meta = TAGS.filter(function (row) {
return row[0] === actor.tag;
})[0];
return [actor.tag, meta[1], meta[2], actor.count, Number(actor.share.toFixed(1)), actor.share > 15 ? 'Chattering - review deadband and delay' : actor.count > 8 ? 'Review limit' : '—'];
}));
current.data = kpi;
current.csv = Audit.toCSV(alarms.map(function (alarm, index) {
return { seq: index + 1, id: alarm.tag + '-' + index, at: Audit.stamp(alarm.raised), system: 'Area 2', user: '', action: 'alarm', record: alarm.tag, field: alarm.message, oldValue: alarm.priority, newValue: Audit.stamp(alarm.cleared), reason: alarm.area };
}), { columns: ['seq', 'at', 'record', 'field', 'oldValue', 'newValue', 'reason'] });
}
else if (kind === 'shift') {
el('reportTitle').textContent = 'Shift report';
el('reportSubtitle').textContent = 'Line 3 · day shift 06:00–14:00 · J. Moreau';
tile('Produced', shift.produced, 'units', shift.produced >= shift.target ? 'ok' : 'warning', 'target ' + shift.target.toLocaleString('en-US'));
tile('OEE', shift.oee, '%', shift.oee >= 85 ? 'ok' : 'warning', 'target 85 %');
tile('Availability', shift.availability, '%', 'ok');
tile('Performance', shift.performance, '%', 'ok');
tile('Quality', shift.quality, '%', 'ok');
tile('Downtime', shift.downtime.reduce(function (sum, row) {
return sum + row[1];
}, 0), 'min', 'warning', shift.downtime.length + ' causes');
el('chartTitle').textContent = 'Units per hour';
chart.dataSource = shift.hourly.map(function (count, index) {
return { hour: (6 + index) + ':00', count: count, target: 1350 };
});
chart.xAxis = { dataField: 'hour', gridLines: { visible: false } };
chart.seriesGroups = [{ type: 'column', columnsGapPercent: 40, valueAxis: { minValue: 0, maxValue: 1600, unitInterval: 400, gridLines: { visible: true, dashStyle: '2,2' } },
series: [{ dataField: 'count', displayText: 'Produced', fillColor: palette.series[0], lineColor: palette.series[0] }, { dataField: 'target', displayText: 'Target', fillColor: 'transparent', lineColor: palette.series[1], lineWidth: 2 }] }];
chart.update();
el('chart2Title').textContent = 'Downtime by cause';
chart2.dataSource = shift.downtime.map(function (row) {
return { cause: row[0], minutes: row[1] };
});
chart2.xAxis = { dataField: 'cause', gridLines: { visible: false } };
chart2.seriesGroups = [{ type: 'column', columnsGapPercent: 45, valueAxis: { minValue: 0, unitInterval: 5, gridLines: { visible: true, dashStyle: '2,2' } },
series: [{ dataField: 'minutes', displayText: 'Minutes', fillColor: palette.series[2], lineColor: palette.series[2] }] }];
chart2.update();
el('tableTitle').textContent = 'Downtime events';
tableOf(['Cause', 'Minutes', 'Station', 'Comment'], shift.downtime.map(function (row) {
return [row[0], row[1], row[0].indexOf('Capper') !== -1 ? 'Capper' : row[0].indexOf('Label') !== -1 ? 'Labeller' : row[0].indexOf('Cartoner') !== -1 ? 'Cartoner' : 'Line', row[1] > 10 ? 'Work order raised' : '—'];
}));
current.data = shift;
current.csv = 'cause,minutes\r\n' + shift.downtime.map(function (row) {
return row[0] + ',' + row[1];
}).join('\r\n') + '\r\n';
}
else if (kind === 'batch') {
el('reportTitle').textContent = 'Batch report ' + batch.id;
el('reportSubtitle').textContent = batch.product + ' · ' + batch.recipe + ' · R-201';
tile('Batch size', batch.size, 'kg', 'neutral');
tile('Yield', batch.yield, '%', batch.yield >= 97 ? 'ok' : 'warning', 'limit ≥ 97 %');
tile('Phases', batch.phases.length, '', 'ok', 'all complete');
tile('Deviations', batch.deviations.length, '', batch.deviations.length ? 'warning' : 'ok', 'all closed');
tile('Duration', '4 h 18 min', '', 'neutral', 'planned 4 h 00 min');
el('chartTitle').textContent = 'Phase durations';
chart.dataSource = batch.phases.map(function (phase) {
const start = phase[1].split(':'), end = phase[2].split(':');
return { phase: phase[0], minutes: (Number(end[0]) * 60 + Number(end[1])) - (Number(start[0]) * 60 + Number(start[1])) };
});
chart.xAxis = { dataField: 'phase', gridLines: { visible: false } };
chart.seriesGroups = [{ type: 'column', columnsGapPercent: 40, valueAxis: { minValue: 0, unitInterval: 20, description: 'min', gridLines: { visible: true, dashStyle: '2,2' } },
series: [{ dataField: 'minutes', displayText: 'Minutes', fillColor: palette.series[0], lineColor: palette.series[0] }] }];
chart.update();
el('chart2Title').textContent = 'Deviations';
chart2.dataSource = batch.deviations.map(function (row) {
return { id: row[0], count: 1 };
});
chart2.xAxis = { dataField: 'id', gridLines: { visible: false } };
chart2.seriesGroups = [{ type: 'column', columnsGapPercent: 60, valueAxis: { minValue: 0, maxValue: 2, unitInterval: 1 }, series: [{ dataField: 'count', displayText: 'Deviations', fillColor: palette.series[3], lineColor: palette.series[3] }] }];
chart2.update();
el('tableTitle').textContent = 'Phases';
tableOf(['Phase', 'Start', 'End', 'Result'], batch.phases);
current.data = batch;
current.csv = 'phase,start,end,result\r\n' + batch.phases.map(function (row) {
return row.join(',');
}).join('\r\n') + '\r\n';
}
else {
el('reportTitle').textContent = 'SPC report';
el('reportSubtitle').textContent = 'Line 3 filler · fill weight · ' + periodText;
el('spc').data = fills.slice();
el('spc').specLimits = { lsl: 9.9, usl: 10.1, target: 10 };
const capability = el('spc').capability ? el('spc').capability() : null,
violations = el('spc').violations ? el('spc').violations() : [];
tile('Subgroups', fills.length, '', 'neutral', 'n = 5');
tile('Cpk', capability && capability.cpk !== undefined ? Number(capability.cpk.toFixed(2)) : '—', '', capability && capability.cpk >= 1.33 ? 'ok' : 'warning', 'target ≥ 1.33');
tile('Ppk', capability && capability.ppk !== undefined ? Number(capability.ppk.toFixed(2)) : '—', '', 'neutral');
tile('Rule violations', violations.length, '', violations.length ? 'warning' : 'ok', 'Western Electric');
el('tableTitle').textContent = 'Rule violations';
tableOf(['Subgroup', 'Rules'], violations.length ? violations.map(function (violation) {
return [violation.index + 1, (violation.rules || []).join(', ')];
}) : [['—', 'None']]);
current.data = { fills: fills, capability: capability };
current.csv = 'subgroup,' + [1, 2, 3, 4, 5].map(function (n) {
return 'x' + n;
}).join(',') + '\r\n' + fills.map(function (group, index) {
return (index + 1) + ',' + group.join(',');
}).join('\r\n') + '\r\n';
}
const hash = Audit.recordHash({ kind: kind, data: current.data, changes: trail.head });
current.hash = hash;
el('reportGenerated').textContent = 'Generated ' + Audit.formatStamp(Audit.stamp());
el('reportHash').textContent = 'Report hash ' + hash.substring(0, 16) + '…';
el('reportHash').title = 'SHA-256 ' + hash;
el('signatureList').innerHTML = '';
el('signatureList').appendChild(document.createElement('smart-signature-block'));
}
/* ── The picker ─────────────────────────────────────────────────────── */
picker.positions = [{ id: 'alarms', label: 'Alarm performance' }, { id: 'shift', label: 'Shift' }, { id: 'batch', label: 'Batch' }, { id: 'spc', label: 'SPC' }];
picker.value = 'alarms';
picker.addEventListener('positionRequest', function (event) {
picker.value = event.detail.position;
render(event.detail.position);
});
el('period').addEventListener('change', function () {
render(current.kind);
});
/* ── Export, print, sign ────────────────────────────────────────────── */
el('exportCsv').addEventListener('click', function () {
const url = URL.createObjectURL(new Blob([current.csv], { type: 'text/csv;charset=utf-8' })),
anchor = document.createElement('a');
anchor.href = url;
anchor.download = current.kind + '-report.csv';
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
setTimeout(function () {
URL.revokeObjectURL(url);
}, 1000);
});
el('print').addEventListener('click', function () {
window.print();
});
el('signReport').addEventListener('click', function () {
const reportRecord = { id: current.kind + '-report', title: el('reportTitle').textContent, fields: [{ label: 'Period', value: el('reportSubtitle').textContent }, { label: 'Report hash', value: current.hash.substring(0, 16) + '…' }] };
sign.sign(reportRecord, { meaning: 'reviewed' }).then(function (signature) {
const block = document.createElement('smart-signature-block');
block.signature = signature;
block.record = reportRecord;
block.compact = true;
const list = el('signatureList');
if (list.firstChild && !list.firstChild.signature) {
list.removeChild(list.firstChild);
}
list.appendChild(block);
}, function () { });
});
sign.addEventListener('signRequest', function (event) {
const users = { jmoreau: ['operator1', 'J. Moreau'], asmith: ['qa2026', 'A. Smith'] };
setTimeout(function () {
const user = users[event.detail.userId];
if (user && user[0] === event.detail.password) {
sign.accept({ userName: user[1] });
}
else {
sign.reject();
}
}, 600);
});
el('themeToggle').addEventListener('click', function () {
const dark = document.body.getAttribute('theme') === 'industrial-dark',
theme = dark ? 'industrial' : 'industrial-dark';
document.body.setAttribute('theme', theme);
el('themeToggle').textContent = dark ? 'Dark' : 'Light';
document.querySelectorAll('smart-chart').forEach(function (node) {
node.theme = theme;
});
});
render('alarms');
};
styles.css
/* Reports. A page that reads on screen and prints on paper: the chrome goes when it
prints, the report stays, and the signature block prints with it. */
html,
body {
height: 100%;
margin: 0;
}
body.viewport {
background: var(--smart-industrial-canvas, var(--smart-body-background-color));
color: var(--smart-text-color);
font-family: var(--smart-font-family);
font-size: 13px;
}
.rp-screen {
gap: 12px;
}
.rp-range {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.rp-range select {
min-height: 28px;
font: inherit;
font-size: 12px;
color: var(--smart-text-color);
background: var(--smart-industrial-panel-background, var(--smart-surface));
border: 1px solid var(--smart-industrial-panel-border);
border-radius: 3px;
}
/* ── The page ────────────────────────────────────────────────────────── */
.rp-report {
padding: 20px 28px 28px;
gap: 18px;
max-width: 1400px;
width: 100%;
margin: 0 auto;
box-sizing: border-box;
}
.rp-title {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
padding-bottom: 12px;
border-bottom: 2px solid var(--smart-industrial-panel-border);
}
.rp-title h1 {
margin: 0 0 4px;
font-size: 22px;
}
.rp-subtitle {
margin: 0;
opacity: 0.8;
}
.rp-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
font-size: 12px;
opacity: 0.85;
}
.rp-hash {
font-family: Consolas, Monaco, monospace;
font-size: 11px;
}
.rp-tiles {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 10px;
}
.rp-section {
display: flex;
flex-direction: column;
gap: 10px;
}
.rp-section[hidden] {
display: none;
}
.rp-section h2,
.rp-chart-block h2,
.rp-signatures h2 {
margin: 0;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
opacity: 0.85;
}
#sectionCharts {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(380px, 1fr));
gap: 16px;
}
#sectionCharts[hidden] {
display: none;
}
.rp-chart-block {
display: flex;
flex-direction: column;
gap: 8px;
}
.rp-chart-block smart-chart {
width: 100%;
height: 240px;
}
#sectionSpc smart-control-chart {
width: 100%;
height: 360px;
}
.rp-table td:first-child {
white-space: nowrap;
}
#sectionRecord {
--smart-audit-max-height: 320px;
}
.rp-signatures {
display: flex;
flex-direction: column;
gap: 10px;
padding-top: 12px;
border-top: 1px solid var(--smart-industrial-panel-border);
}
.rp-signature-list {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
smart-esignature {
position: fixed;
right: 24px;
top: 80px;
z-index: 31;
}
/* ── Paper ───────────────────────────────────────────────────────────── */
@media print {
body.viewport {
background: #fff;
color: #000;
}
.rp-chrome,
smart-esignature {
display: none !important;
}
.hmi-screen {
padding: 0;
}
.rp-report {
border: none;
box-shadow: none;
max-width: none;
padding: 0;
}
.rp-section,
.rp-chart-block {
break-inside: avoid;
}
}
Components on this screen
Audit TrailControl ChartE-SignatureSelector SwitchSignature BlockStatus Tile