Operator screen
Andon
An andon board with a stack light per station, a state timeline for the shift, counts against target, calls and alarms.
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>Andon Board - 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 an-screen">
<header class="hmi-header">
<div class="hmi-identity">
<span class="hmi-plant">LINE 2 ANDON</span>
<span class="hmi-area">Packaging hall · shift B · <span id="clock"></span></span>
</div>
<div class="hmi-header-status an-shift" id="shift" role="status"></div>
<div class="hmi-header-actions">
<button class="hmi-button" id="feed" type="button">Pause the line</button>
<button class="hmi-theme-toggle" id="themeToggle" type="button">Light</button>
</div>
</header>
<section class="hmi-body an-body">
<div class="hmi-panel an-stations-panel">
<div class="hmi-panel-header">
<span>Stations</span>
<span class="an-panel-hint">A tower per station; press a horn to ask for it to be silenced</span>
</div>
<div class="hmi-panel-content an-stations" id="stations"></div>
</div>
<div class="an-row">
<div class="hmi-panel an-timeline-panel">
<div class="hmi-panel-header">
<span>This shift</span>
<span class="an-panel-hint" id="timelineHint">Arrows walk a machine's day; up and down compare machines at the same minute</span>
</div>
<div class="hmi-panel-content">
<smart-state-timeline id="timeline" label="Line 2" show-summary></smart-state-timeline>
</div>
</div>
<div class="hmi-panel an-kpi-panel">
<div class="hmi-panel-header">
<span>Shift so far</span>
</div>
<div class="hmi-panel-content an-kpis">
<smart-status-tile id="tileCount" label="Cases packed" unit="" precision-digits="0"></smart-status-tile>
<smart-status-tile id="tileTarget" label="Against target" unit="%" precision-digits="1"></smart-status-tile>
<smart-status-tile id="tileAvailability" label="Line availability" unit="%" precision-digits="1"></smart-status-tile>
<smart-status-tile id="tileCalls" label="Open calls" unit="" precision-digits="0"></smart-status-tile>
</div>
<div class="hmi-panel-header an-calls-header">
<span>Calls</span>
</div>
<div class="hmi-panel-content">
<ol class="an-calls" id="calls"></ol>
</div>
</div>
</div>
<div class="hmi-panel">
<div class="hmi-panel-header">
<span>Alarms</span>
</div>
<div class="hmi-panel-content">
<smart-alarm-banner id="banner" max-visible="3"></smart-alarm-banner>
</div>
</div>
</section>
</div>
<!-- scripts -->
<script type="module" src="node_modules/smart-industrial/source/modules/smart.stacklight.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.statustile.js"></script>
<script type="module" src="node_modules/smart-industrial/source/modules/smart.alarmbanner.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" />
/* Andon board.
*
* The screen on the wall of a packaging hall: a tower light per station, the shift so far
* as a state timeline, the counts against target, the open operator calls and the alarm
* strip. The line here is a stand-in that changes state every few seconds; a plant's comes
* from the machines' controllers through Connect, and nothing on the screen would change.
* The horn is the one thing the board asks the plant for rather than does: a press raises
* muteRequest, the host silences the horn, and the light shows the answer. */
window.onload = function () {
const stationsHost = document.getElementById('stations'),
timeline = document.getElementById('timeline'),
banner = document.getElementById('banner'),
calls = document.getElementById('calls'),
shift = document.getElementById('shift'),
clock = document.getElementById('clock'),
feedButton = document.getElementById('feed'),
themeToggle = document.getElementById('themeToggle'),
tiles = {
count: document.getElementById('tileCount'),
target: document.getElementById('tileTarget'),
availability: document.getElementById('tileAvailability'),
calls: document.getElementById('tileCalls')
},
MIN = 60000,
H = 3600000,
SHIFT_START = Date.now() - 5.6 * H,
RATE = 12; /* cases per minute at full speed */
/* ── The line ───────────────────────────────────────────────────────── */
const STATIONS = [
{ id: 'depal', label: 'Depalletiser', state: 'running' },
{ id: 'filler', label: 'Filler', state: 'running' },
{ id: 'capper', label: 'Capper', state: 'running' },
{ id: 'labeller', label: 'Labeller', state: 'starved' },
{ id: 'packer', label: 'Case packer', state: 'fault', horn: 'on', reason: 'Glue unit temperature' },
{ id: 'palletiser', label: 'Palletiser', state: 'blocked' }
];
const lights = {},
openCalls = [];
let alarms = [],
nextAlarm = 1,
timer = null;
STATIONS.forEach(function (station) {
const cell = document.createElement('div'),
light = document.createElement('smart-stack-light'),
name = document.createElement('span'),
word = document.createElement('span');
cell.className = 'an-station';
light.label = station.label;
light.state = station.state;
light.horn = station.horn || 'off';
light.segments = ['red', 'amber', 'green', 'blue'];
name.className = 'an-station-name';
name.textContent = station.label;
word.className = 'an-station-state';
word.textContent = station.state;
cell.appendChild(light);
cell.appendChild(name);
cell.appendChild(word);
stationsHost.appendChild(cell);
lights[station.id] = { light: light, word: word, station: station };
light.addEventListener('muteRequest', function () {
/* the host silences the horn output; the light shows the answer */
light.horn = 'muted';
record(station.label + ': horn silenced from the board');
});
light.addEventListener('stateChange', function (event) {
word.textContent = event.detail.state;
});
light.addEventListener('segmentClick', function () {
timeline.selected = { row: station.id, from: lastFrom(station.id) };
timelineHint(station.label + ' selected on the timeline');
});
});
/* ── The shift so far ───────────────────────────────────────────────── */
function history(station) {
const segments = [],
/* the current state began four minutes ago; nothing before it may run past that */
now = Date.now(),
current = now - 4 * MIN;
let at = SHIFT_START - 25 * MIN;
segments.push({ state: 'changeover', from: at, to: SHIFT_START + 8 * MIN });
at = SHIFT_START + 8 * MIN;
const seed = station.id.length * 7;
/* Every end is clamped to the start of the current state. The loop stopped only once
a cycle had begun past now - 30 min, so a long run plus a stop could end in the
future, and the closing run then went from there back to four minutes ago - a
segment ending before it began, which the timeline warned about on every load. */
for (let i = 0; at < now - 30 * MIN; i++) {
const running = 40 + ((seed * (i + 3)) % 55),
trouble = 6 + ((seed * (i + 1)) % 14),
kind = ['starved', 'blocked', 'fault', 'starved'][(seed + i) % 4],
reason = { starved: 'Upstream', blocked: 'Downstream full', fault: ['Jam at infeed', 'Guard door', 'Glue unit temperature', 'Sensor fault'][(seed + i) % 4] }[kind],
runEnd = Math.min(at + running * MIN, current),
troubleEnd = Math.min(runEnd + trouble * MIN, current);
segments.push({ state: 'running', from: at, to: runEnd });
if (troubleEnd > runEnd) {
segments.push({ state: kind, from: runEnd, to: troubleEnd, reason: reason });
}
at = troubleEnd;
}
if (at < current) {
segments.push({ state: 'running', from: at, to: current });
}
segments.push(station.state === 'running' ? { state: 'running', from: current } : { state: station.state, from: current, reason: station.reason });
return segments;
}
timeline.timeSpan = 6 * H;
timeline.showLegend = false;
timeline.rows = STATIONS.map(function (station) {
return { id: station.id, label: station.label, segments: history(station) };
});
function lastFrom(id) {
const row = timeline.rows.find(function (candidate) {
return candidate.id === id;
}),
last = row.segments[row.segments.length - 1];
return last.from;
}
timeline.addEventListener('segmentClick', function (event) {
const minutes = Math.round((event.detail.to - event.detail.from) / MIN),
reason = event.detail.segment && event.detail.segment.reason;
timelineHint(event.detail.row + ': ' + event.detail.state + ' for ' + minutes + ' min' + (reason ? ' - ' + reason : ''));
});
function timelineHint(text) {
document.getElementById('timelineHint').textContent = text;
}
/* ── Calls, alarms and the counts ───────────────────────────────────── */
function record(text) {
openCalls.unshift({ at: new Date(), text: text });
openCalls.splice(8);
calls.innerHTML = openCalls.map(function (call) {
return '<li><time>' + call.at.toLocaleTimeString('en-GB', { hour12: false, hour: '2-digit', minute: '2-digit' }) + '</time>' + call.text + '</li>';
}).join('');
tiles.calls.value = openCalls.length;
tiles.calls.state = openCalls.length > 3 ? 'warning' : 'ok';
}
function raise(station, message, priority) {
alarms = [{ id: nextAlarm++, tag: station.label, message: message, priority: priority, timestamp: new Date(), acknowledged: false }].concat(alarms).slice(0, 12);
banner.alarms = alarms;
}
banner.addEventListener('acknowledge', function (event) {
alarms = alarms.map(function (alarm) {
return alarm.id === event.detail.alarm.id ? Object.assign({}, alarm, { acknowledged: true }) : alarm;
});
banner.alarms = alarms;
});
raise(STATIONS[4], 'Glue unit temperature low - case packer stopped', 2);
raise(STATIONS[3], 'Labeller starved: no bottles from the capper', 3);
record('Case packer: maintenance called for the glue unit');
record('Labeller: operator call - label roll change');
function counts() {
const elapsed = (Date.now() - SHIFT_START) / MIN,
availability = timeline.summary('packer').filter(function (item) {
return item.state === 'running';
}).reduce(function (sum, item) {
return sum + item.percent;
}, 0),
packed = Math.round(elapsed * RATE * availability / 100),
target = Math.round(elapsed * RATE * 0.85);
tiles.count.value = packed;
tiles.count.footnote = 'target ' + target;
tiles.target.value = target ? packed / target * 100 : 0;
tiles.target.state = packed >= target ? 'ok' : packed >= target * 0.9 ? 'warning' : 'critical';
tiles.availability.value = availability;
tiles.availability.state = availability >= 80 ? 'ok' : availability >= 65 ? 'warning' : 'critical';
shift.textContent = 'Shift B, ' + Math.floor(elapsed / 60) + ' h ' + Math.round(elapsed % 60) + ' min in: ' + packed + ' cases, ' + STATIONS.filter(function (station) {
return lights[station.id].light.state !== 'running';
}).length + ' stations not running';
}
/* ── The feed: the line moves ───────────────────────────────────────── */
const STORIES = [
{ id: 'packer', state: 'running', horn: 'off' },
{ id: 'labeller', state: 'running' },
{ id: 'palletiser', state: 'running' },
{ id: 'capper', state: 'starved', reason: 'Bottle supply' },
{ id: 'capper', state: 'running' },
{ id: 'filler', state: 'fault', horn: 'on', reason: 'Fill level sensor', alarm: 'Fill level sensor fault - filler stopped', priority: 1 },
{ id: 'capper', state: 'starved', reason: 'Filler fault' },
{ id: 'labeller', state: 'starved', reason: 'Filler fault' },
{ id: 'filler', state: 'running', horn: 'off' },
{ id: 'capper', state: 'running' },
{ id: 'labeller', state: 'running' },
{ id: 'palletiser', state: 'call', alarm: 'Palletiser: operator call - stretch film change', priority: 3, call: 'Palletiser: operator call - stretch film change' },
{ id: 'palletiser', state: 'running' }
];
let step = 0;
function tick() {
const story = STORIES[step % STORIES.length],
entry = lights[story.id];
step++;
entry.light.state = story.state;
if (story.horn) {
entry.light.horn = story.horn;
}
timeline.push(story.id, { state: story.state === 'call' ? 'running' : story.state, from: Date.now(), reason: story.reason });
if (story.alarm) {
raise(entry.station, story.alarm, story.priority);
}
if (story.call) {
record(story.call);
}
counts();
}
function startFeed() {
timer = setInterval(tick, 6000);
feedButton.textContent = 'Pause the line';
}
feedButton.addEventListener('click', function () {
if (timer) {
clearInterval(timer);
timer = null;
feedButton.textContent = 'Resume the line';
}
else {
startFeed();
}
});
setInterval(function () {
clock.textContent = new Date().toLocaleTimeString('en-GB', { hour12: false, hour: '2-digit', minute: '2-digit' });
}, 1000);
clock.textContent = new Date().toLocaleTimeString('en-GB', { hour12: false, hour: '2-digit', minute: '2-digit' });
/* ── Theme ───────────────────────────────────────────────────────── */
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';
});
counts();
startFeed();
};
styles.css
/* Andon board. The stations across the top are the thing read from across the hall; the
timeline, the counts and the calls are for whoever walks up to 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;
}
.an-screen {
gap: 12px;
}
.an-shift {
font-size: 12px;
color: var(--smart-text-secondary-color);
}
.an-body {
display: flex;
flex-direction: column;
gap: 12px;
}
.an-panel-hint {
font-size: 11px;
font-weight: 400;
text-transform: none;
letter-spacing: 0;
opacity: 0.75;
}
.an-stations {
display: flex;
flex-wrap: wrap;
justify-content: space-around;
gap: 18px 28px;
padding: 18px 12px;
}
.an-station {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.an-station smart-stack-light {
--smart-stack-segment-width: 44px;
--smart-stack-segment-height: 30px;
/* room for the horn on every tower, so the names line up whether it is shown or not */
height: 176px;
}
.an-station-name {
font-size: 15px;
font-weight: 600;
}
.an-station-state {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--smart-text-secondary-color);
}
.an-row {
display: grid;
grid-template-columns: minmax(0, 3fr) minmax(300px, 1.2fr);
gap: 12px;
}
@media (max-width: 1000px) {
.an-row {
grid-template-columns: 1fr;
}
}
smart-state-timeline {
--smart-timeline-row-height: 30px;
--smart-timeline-label-width: 150px;
width: 100%;
}
.an-kpis {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.an-calls-header {
border-top: 1px solid var(--smart-industrial-panel-border);
}
.an-calls {
margin: 0;
padding: 0 0 0 4px;
list-style: none;
display: flex;
flex-direction: column;
gap: 6px;
font-size: 12px;
}
.an-calls time {
display: inline-block;
min-width: 46px;
margin-right: 8px;
color: var(--smart-text-secondary-color);
font-variant-numeric: tabular-nums;
}
smart-alarm-banner {
width: 100%;
}