Operator screen
Packaging Oee
OEE with its three losses beside the shift timeline, the stops waiting for a reason, the downtime Pareto and the order on the line.
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>Packaging Line OEE - 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 oee-screen">
<header class="hmi-header">
<div class="hmi-identity">
<span class="hmi-plant">LINE 3 · PACKAGING</span>
<span class="hmi-area">Bottling hall · shift A · <span id="clock"></span></span>
</div>
<div class="hmi-header-status" id="onLine" role="status"></div>
<div class="hmi-header-actions">
<button class="hmi-button" id="stop" type="button">Stop the capper</button>
<button class="hmi-theme-toggle" id="themeToggle" type="button">Light</button>
</div>
</header>
<section class="hmi-body oee-body">
<div class="oee-tiles">
<smart-status-tile id="tileOee" label="OEE" unit="%" precision-digits="1" target="85"></smart-status-tile>
<smart-status-tile id="tileAvailability" label="Availability" unit="%" precision-digits="1"></smart-status-tile>
<smart-status-tile id="tilePerformance" label="Performance" unit="%" precision-digits="1"></smart-status-tile>
<smart-status-tile id="tileQuality" label="Quality" unit="%" precision-digits="1"></smart-status-tile>
<smart-status-tile id="tileUnexplained" label="Stops without a reason" unit="" precision-digits="0"></smart-status-tile>
</div>
<div class="oee-row">
<div class="hmi-panel">
<div class="hmi-panel-header">
<span>This shift</span>
<span class="oee-hint">The filler sets the line's pace; a stop anywhere shows here first</span>
</div>
<div class="hmi-panel-content">
<smart-state-timeline id="timeline" label="Line 3" show-summary></smart-state-timeline>
</div>
</div>
<div class="hmi-panel">
<div class="hmi-panel-header">
<span>Where the time went</span>
</div>
<div class="hmi-panel-content">
<smart-comparison-chart id="pareto" label="Downtime by reason" unit="min" orientation="vertical" pareto precision-digits="0"></smart-comparison-chart>
</div>
</div>
</div>
<div class="oee-row">
<div class="hmi-panel">
<div class="hmi-panel-header">
<span>Stops</span>
<span class="oee-hint">An availability loss is only as useful as the reason given for it</span>
</div>
<div class="hmi-panel-content">
<smart-downtime-log id="downtime" label="Line 3 stops"></smart-downtime-log>
</div>
</div>
<div class="hmi-panel">
<div class="hmi-panel-header">
<span>Orders</span>
</div>
<div class="hmi-panel-content">
<smart-order-queue id="orders" label="Line 3 orders"></smart-order-queue>
</div>
</div>
</div>
</section>
</div>
<!-- scripts -->
<script type="module" src="node_modules/smart-industrial/source/modules/smart.statustile.js"></script>
<script type="module" src="node_modules/smart-industrial/source/modules/smart.statetimeline.js"></script>
<script type="module" src="node_modules/smart-industrial/source/modules/smart.orderqueue.js"></script>
<script type="module" src="node_modules/smart-industrial/source/modules/smart.downtimelog.js"></script>
<script type="module" src="node_modules/smart-industrial/source/modules/smart.comparisonchart.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" />
/* Packaging line OEE.
*
* OEE is the number a plant manager asks for first, and it is three losses multiplied:
* availability (the line stopped), performance (it ran slow) and quality (it made scrap). This
* screen puts the number beside the things that make it: the shift as a state timeline, the
* stops waiting for a reason, the Pareto of where the time went, and the order the line is on.
* Giving a stop its reason moves its minutes from "No reason yet" to their cause in the Pareto -
* which is the whole argument for asking. The line is a stand-in; a plant's comes from its
* controllers through the station, which opens and closes the stops itself. */
window.onload = function () {
const timeline = document.getElementById('timeline'),
orders = document.getElementById('orders'),
downtime = document.getElementById('downtime'),
pareto = document.getElementById('pareto'),
onLine = document.getElementById('onLine'),
clock = document.getElementById('clock'),
stopButton = document.getElementById('stop'),
themeToggle = document.getElementById('themeToggle'),
tiles = {
oee: document.getElementById('tileOee'),
availability: document.getElementById('tileAvailability'),
performance: document.getElementById('tilePerformance'),
quality: document.getElementById('tileQuality'),
unexplained: document.getElementById('tileUnexplained')
},
MIN = 60000,
H = 3600000,
NOW = Date.now(),
SHIFT_START = NOW - 5.2 * H,
IDEAL = 200; /* bottles a minute at the filler's rated speed */
const REASONS = [
{ code: 'changeover', label: 'Changeover', group: 'Planned' },
{ code: 'cleaning', label: 'Cleaning', group: 'Planned' },
{ code: 'break', label: 'Break', group: 'Planned' },
{ code: 'breakdown', label: 'Breakdown', group: 'Unplanned' },
{ code: 'jam', label: 'Jam', group: 'Unplanned' },
{ code: 'starved', label: 'Starved (no material)', group: 'Unplanned' },
{ code: 'blocked', label: 'Blocked (downstream full)', group: 'Unplanned' },
{ code: 'quality', label: 'Quality hold', group: 'Unplanned' },
{ code: 'other', label: 'Other', group: 'Unplanned' }
];
/* ── The shift so far ───────────────────────────────────────────────── */
const MACHINES = [
{ id: 'filler', name: 'Filler' },
{ id: 'capper', name: 'Capper' },
{ id: 'labeller', name: 'Labeller' },
{ id: 'packer', name: 'Case packer' },
{ id: 'palletiser', name: 'Palletiser' }
];
/* [machine, minutes after shift start, minutes long, state, reason already given] */
const HISTORY = [
['filler', 0, 26, 'changeover', 'changeover'],
['filler', 71, 7, 'fault', ''],
['capper', 118, 4, 'fault', 'jam'],
['filler', 150, 30, 'stopped', 'break'],
['labeller', 196, 9, 'starved', ''],
['filler', 232, 12, 'fault', 'jam'],
['packer', 262, 6, 'fault', ''],
['palletiser', 280, 3, 'blocked', 'blocked'],
['labeller', 101, 0.3, 'fault', ''],
['capper', 207, 0.4, 'fault', '']
];
let next = 1;
const stops = HISTORY.map(function (h) {
const from = SHIFT_START + h[1] * MIN,
seconds = Math.round(h[2] * 60);
return { id: 'st' + next++, machine: h[0], machineName: MACHINES.find((m) => m.id === h[0]).name, from: new Date(from).toISOString(), to: new Date(from + seconds * 1000).toISOString(), seconds: seconds, short: seconds < 30, reason: h[4], classifiedBy: h[4] ? 'A. Brandt' : '', state: h[3] };
});
function rows() {
return MACHINES.map(function (machine) {
const own = stops.filter((s) => s.machine === machine.id).sort((a, b) => a.from.localeCompare(b.from)),
segments = [];
let at = SHIFT_START;
own.forEach(function (stop) {
const from = Date.parse(stop.from),
to = stop.to ? Date.parse(stop.to) : null;
if (from > at) {
segments.push({ state: 'running', from: at, to: from });
}
segments.push({ state: stop.state, from: from, to: to, reason: stop.reason ? REASONS.find((r) => r.code === stop.reason).label : 'no reason yet' });
at = to || Date.now();
});
if (!own.length || own[own.length - 1].to) {
segments.push({ state: 'running', from: at });
}
return { id: machine.id, label: machine.name, segments: segments };
});
}
/* ── The numbers ────────────────────────────────────────────────────── */
const order = { id: 'ORD-24117', product: 'Still water 0.5 l, 24-pack', quantity: 60000, unit: 'bottles', due: new Date(SHIFT_START + 8 * H).toISOString(), recipe: 'still-05', status: 'running', started: new Date(SHIFT_START + 26 * MIN).toISOString() };
const QUEUE = [
order,
{ id: 'ORD-24118', product: 'Sparkling water 0.5 l, 24-pack', quantity: 24000, unit: 'bottles', due: new Date(SHIFT_START + 11 * H).toISOString(), recipe: 'spark-05', status: 'queued', produced: 0 },
{ id: 'ORD-24121', product: 'Still water 1.5 l, 6-pack', quantity: 18000, unit: 'bottles', due: new Date(SHIFT_START + 14 * H).toISOString(), recipe: 'still-15', status: 'queued', produced: 0 }
];
let scrap = 540;
function numbers() {
const now = Date.now(),
planned = now - SHIFT_START - 30 * MIN, /* the break is planned, not lost */
fillerDown = stops.filter((s) => s.machine === 'filler' && s.reason !== 'break').reduce((sum, s) => sum + ((s.to ? Date.parse(s.to) : now) - Date.parse(s.from)), 0),
running = planned - fillerDown,
produced = Math.round(running / MIN * IDEAL * 0.91),
availability = running / planned,
performance = produced / (running / MIN * IDEAL),
good = produced - scrap,
quality = good / Math.max(1, produced);
order.produced = good;
return { availability: availability * 100, performance: performance * 100, quality: quality * 100, oee: availability * performance * quality * 100, good: good };
}
function show() {
const n = numbers(),
unexplained = stops.filter((s) => !s.reason && !s.short).length,
byReason = {};
tiles.oee.value = n.oee;
tiles.oee.state = n.oee >= 85 ? 'ok' : n.oee >= 65 ? 'neutral' : 'warning';
tiles.availability.value = n.availability;
tiles.performance.value = n.performance;
tiles.quality.value = n.quality;
tiles.unexplained.value = unexplained;
tiles.unexplained.state = unexplained ? 'warning' : 'ok';
stops.filter((s) => !s.short).forEach(function (s) {
const key = s.reason || 'none',
length = ((s.to ? Date.parse(s.to) : Date.now()) - Date.parse(s.from)) / MIN;
byReason[key] = (byReason[key] || 0) + length;
});
pareto.items = Object.keys(byReason).map((key) => ({ field: key, label: key === 'none' ? 'No reason yet' : REASONS.find((r) => r.code === key).label, value: Math.round(byReason[key] * 10) / 10 }));
orders.orders = QUEUE.map((o) => Object.assign({}, o));
onLine.textContent = order.product + ' · ' + n.good.toLocaleString('en') + ' of ' + order.quantity.toLocaleString('en');
}
function publishStops() {
downtime.reasons = REASONS;
downtime.stops = stops.map((s) => Object.assign({}, s));
timeline.rows = rows();
}
/* ── What the operator does ─────────────────────────────────────────── */
downtime.addEventListener('stopClassify', function (event) {
const detail = event.detail,
stop = stops.find((s) => s.id === detail.id);
downtime.busy = detail.id;
setTimeout(function () {
downtime.busy = '';
Object.assign(stop, { reason: detail.reason, comment: detail.comment, classifiedBy: 'A. Brandt' });
publishStops();
show();
}, 300);
});
orders.addEventListener('orderAction', function (event) {
const detail = event.detail,
target = QUEUE.find((o) => o.id === detail.id);
if (detail.action === 'start' && QUEUE.some((o) => o.status === 'running' || o.status === 'paused')) {
orders.error = 'Another order is on the line: complete or abort it first.';
return;
}
orders.error = '';
target.status = { start: 'running', resume: 'running', pause: 'paused', complete: 'done', abort: 'aborted' }[detail.action];
show();
});
stopButton.addEventListener('click', function () {
const open = stops.find((s) => s.machine === 'capper' && !s.to);
if (open) {
open.to = new Date().toISOString();
open.seconds = Math.round((Date.parse(open.to) - Date.parse(open.from)) / 1000);
open.short = open.seconds < 30;
stopButton.textContent = 'Stop the capper';
}
else {
stops.push({ id: 'st' + next++, machine: 'capper', machineName: 'Capper', from: new Date().toISOString(), to: null, reason: '', state: 'fault' });
stopButton.textContent = 'Start the capper';
}
publishStops();
show();
});
themeToggle.addEventListener('click', function () {
const theme = document.body.getAttribute('theme') === 'industrial-dark' ? 'industrial' : 'industrial-dark';
document.body.setAttribute('theme', theme);
themeToggle.textContent = theme === 'industrial-dark' ? 'Light' : 'Dark';
});
const time = () => { clock.textContent = new Date().toLocaleTimeString('en-GB', { hour12: false, hour: '2-digit', minute: '2-digit' }); };
orders.showFinished = false;
timeline.timeSpan = 8 * H;
timeline.end = SHIFT_START + 8 * H;
time();
setInterval(time, 15000);
publishStops();
show();
/* the counts move every few seconds; now and then a bottle is rejected */
setInterval(function () {
if (Math.random() < 0.3) {
scrap += 1;
}
show();
}, 3000);
};
styles.css
/* Packaging line OEE. The number across the top, and under it the things that make 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;
}
.oee-body {
display: flex;
flex-direction: column;
gap: 12px;
}
.oee-tiles {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
}
.oee-row {
display: grid;
grid-template-columns: minmax(0, 1.6fr) minmax(360px, 1fr);
gap: 12px;
}
@media (max-width: 1100px) {
.oee-row {
grid-template-columns: 1fr;
}
}
.oee-hint {
font-size: 11px;
font-weight: 400;
text-transform: none;
letter-spacing: 0;
color: var(--smart-text-secondary-color);
}
smart-state-timeline {
--smart-timeline-row-height: 30px;
--smart-timeline-label-width: 150px;
width: 100%;
}
smart-order-queue {
width: 100%;
}
smart-downtime-log {
height: 420px;
}
smart-comparison-chart {
width: 100%;
height: 300px;
}
Components on this screen
Comparison ChartDowntime LogOrder QueueState TimelineStatus Tile