Operator screen
Pump Station
A water network on its own map, the selected pump station with its wet well, pumps, pressure and flow, and the network 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>Pump Stations - 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 ps-screen">
<header class="hmi-header">
<div class="hmi-identity">
<span class="hmi-plant">WATER NETWORK</span>
<span class="hmi-area">Millbrook supply zone · 10 sites · <span id="clock"></span></span>
</div>
<div class="hmi-header-status" id="summary" role="status"></div>
<div class="hmi-header-actions">
<button class="hmi-button" id="trip" type="button">Trip PS East</button>
<button class="hmi-theme-toggle" id="themeToggle" type="button">Light</button>
</div>
</header>
<section class="hmi-body ps-body">
<div class="hmi-panel ps-map-panel">
<div class="hmi-panel-header">
<span>Network</span>
<span class="ps-hint">Select a site; its station opens on the right</span>
</div>
<div class="hmi-panel-content">
<smart-site-map id="map" label="Millbrook network" selected="ps-north" detail="none">
<svg class="region" viewBox="0 0 1600 900" aria-hidden="true" focusable="false">
<path class="lake" d="M1090 610 C1160 560 1300 570 1360 630 C1420 690 1380 790 1270 800 C1160 810 1060 760 1060 700 C1060 660 1070 630 1090 610 Z" />
<path class="river" d="M-20 520 C160 500 260 560 380 600 C520 650 640 620 760 560 C880 500 980 560 1090 640" />
<path class="river thin" d="M760 560 C790 420 740 300 800 180 C830 120 880 60 900 -20" />
<path class="town" d="M620 300 L900 280 L960 420 L880 520 L660 540 L580 430 Z" />
<path class="town" d="M1180 180 L1340 170 L1380 300 L1250 340 L1160 290 Z" />
<path class="main" d="M290 560 L128 270 M290 560 L540 150 L1010 110 M540 150 L810 380 L1060 440 L1310 300 M810 380 L864 610 L1376 680 M864 610 L580 760" />
<text x="690" y="470">Millbrook</text>
<text x="1200" y="245">Eastfield</text>
<text x="1150" y="720">Harbour</text>
</svg>
</smart-site-map>
</div>
</div>
<div class="hmi-panel ps-station-panel">
<div class="hmi-panel-header">
<span id="stationName">Station</span>
<span class="ps-hint" id="stationKind"></span>
</div>
<div class="hmi-panel-content ps-station">
<div class="ps-mimic">
<smart-tank id="well" orientation="vertical" min="0" max="100" unit="%" show-unit scale-position="far" interval="25" precision-digits="0"></smart-tank>
<div class="ps-pumps" id="pumps"></div>
</div>
<div class="ps-tiles">
<smart-status-tile id="tilePressure" label="Discharge" unit="bar" precision-digits="2"></smart-status-tile>
<smart-status-tile id="tileFlow" label="Flow" unit="m³/h" precision-digits="0"></smart-status-tile>
</div>
<smart-trend id="trend" live time-span="600000" label="Discharge pressure and flow"></smart-trend>
<div class="ps-cards" id="cards"></div>
</div>
</div>
<div class="hmi-panel ps-alarms">
<div class="hmi-panel-header">
<span>Network alarms</span>
</div>
<div class="hmi-panel-content">
<smart-alarm-banner id="banner" max-visible="3" empty-message="No active alarms"></smart-alarm-banner>
</div>
</div>
</section>
</div>
<!-- scripts -->
<script type="module" src="node_modules/smart-industrial/source/modules/smart.sitemap.js"></script>
<script type="module" src="node_modules/smart-webcomponents/source/modules/smart.tank.js"></script>
<script type="module" src="node_modules/smart-industrial/source/modules/smart.mimicsymbol.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.trend.js"></script>
<script type="module" src="node_modules/smart-industrial/source/modules/smart.assetcard.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" />
/* Pump stations.
*
* The supervisor's screen of a small water utility: ten sites on the network's own map, and the
* station the supervisor picks opened beside it - its wet well, its pumps, its discharge
* pressure and flow, and the maintenance state of each pump. A site that is fine is grey on the
* map; the one that tripped is the thing on the screen that moves. The network here is a
* stand-in that drifts every two seconds; a utility's comes from its outstations through the
* station agent, and nothing on the screen would change. */
window.onload = function () {
const map = document.getElementById('map'),
banner = document.getElementById('banner'),
trend = document.getElementById('trend'),
well = document.getElementById('well'),
pumpsHost = document.getElementById('pumps'),
cardsHost = document.getElementById('cards'),
stationName = document.getElementById('stationName'),
stationKind = document.getElementById('stationKind'),
summary = document.getElementById('summary'),
clock = document.getElementById('clock'),
tripButton = document.getElementById('trip'),
themeToggle = document.getElementById('themeToggle'),
tilePressure = document.getElementById('tilePressure'),
tileFlow = document.getElementById('tileFlow'),
MIN = 60000;
/* ── The network ────────────────────────────────────────────────────── */
const pump = (id, hours, lastService, extra) => Object.assign({ id: id, running: true, fault: false, hours: hours, lastService: lastService, starts: Math.round(hours / 3.1) }, extra || {});
const SITES = [
{ id: 'wtp', name: 'Riverside treatment works', kind: 'Treatment works', x: 18, y: 62, level: 64, pressure: 4.6, flow: 412, pumps: [pump('P1', 18400, 17000), pump('P2', 17950, 17000), pump('P3', 9200, 8800, { running: false })] },
{ id: 'res-west', name: 'West reservoir', kind: 'Service reservoir', x: 8, y: 30, level: 72, pressure: 2.1, flow: 96, pumps: [] },
{ id: 'ps-north', name: 'PS North', kind: 'Booster pump station', x: 34, y: 17, level: 41, pressure: 0.8, flow: 58, pumps: [pump('P1', 21300, 19500), pump('P2', 20990, 19500, { running: false, fault: true, trip: 'Overcurrent trip' })] },
{ id: 'ps-mill', name: 'PS Mill Lane', kind: 'Booster pump station', x: 51, y: 42, level: 55, pressure: 3.9, flow: 140, pumps: [pump('P1', 12640, 10800), pump('P2', 11020, 10800, { running: false })] },
{ id: 'res-hill', name: 'Hill reservoir', kind: 'Service reservoir', x: 63, y: 12, level: 38, pressure: 1.9, flow: 84, pumps: [], warning: 'Level below the refill band' },
{ id: 'bs-square', name: 'Booster Station Square', kind: 'In-line booster', x: 66, y: 49, level: 60, pressure: 3.2, flow: 88, pumps: [pump('P1', 7800, 6000)] },
{ id: 'ps-east', name: 'PS East', kind: 'Booster pump station', x: 82, y: 33, level: 58, pressure: 4.1, flow: 121, pumps: [pump('P1', 15500, 14000), pump('P2', 15320, 14000)] },
{ id: 'bs-oak', name: 'Booster Oak Street', kind: 'In-line booster', x: 54, y: 68, level: 62, pressure: 2.7, flow: 71, pumps: [pump('P1', 9900, 8200)] },
{ id: 'ps-south', name: 'PS South', kind: 'Booster pump station', x: 36, y: 84, level: 50, pressure: 0, flow: 0, maintenance: 'Valve replacement until 16:00', pumps: [pump('P1', 14100, 12500, { running: false }), pump('P2', 13900, 12500, { running: false })] },
{ id: 'ps-harbour', name: 'PS Harbour', kind: 'Booster pump station', x: 86, y: 76, level: 0, pressure: 0, flow: 0, offline: true, pumps: [pump('P1', 16300, 15000), pump('P2', 16100, 15000)] }
];
let selected = SITES.find((s) => s.id === 'ps-north'),
alarmSince = {};
function stateOf(site) {
if (site.offline) {
return 'offline';
}
if (site.maintenance) {
return 'maintenance';
}
if (site.pumps.some((p) => p.fault) || (site.pumps.length && site.pressure < 1.5)) {
return 'alarm';
}
return site.warning ? 'warning' : 'normal';
}
/* The alarms each site raises, one list for the whole network. */
function alarmsOf(site) {
const list = [];
site.pumps.filter((p) => p.fault).forEach((p) => list.push({ id: site.id + '.' + p.id + '.TRIP', tag: site.name + ' ' + p.id, message: p.trip || 'Pump tripped', priority: 1 }));
if (site.pumps.length && !site.offline && !site.maintenance && site.pressure < 1.5) {
list.push({ id: site.id + '.PT.LO', tag: site.name + ' PT-01', message: 'Discharge pressure low: ' + site.pressure.toFixed(2) + ' bar', priority: 2 });
}
if (site.warning) {
list.push({ id: site.id + '.LT.LO', tag: site.name + ' LT-01', message: site.warning, priority: 3 });
}
if (site.offline) {
list.push({ id: site.id + '.COMMS', tag: site.name, message: 'Outstation not answering', priority: 2 });
}
return list;
}
function publish() {
const alarms = [];
map.sites = SITES.map((site) => {
const own = alarmsOf(site);
alarms.push.apply(alarms, own);
return {
id: site.id,
name: site.name,
x: site.x,
y: site.y,
state: stateOf(site),
alarms: own.length,
reading: site.offline ? 'no data' : site.maintenance ? site.maintenance : site.pumps.length ? site.pressure.toFixed(1) + ' bar · ' + Math.round(site.flow) + ' m³/h' : 'level ' + Math.round(site.level) + ' %',
description: site.kind
};
});
alarms.forEach((alarm) => { alarmSince[alarm.id] = alarmSince[alarm.id] || Date.now() - Math.round(Math.random() * 20) * MIN; });
banner.alarms = alarms.map((alarm) => Object.assign({ acknowledged: !!acked[alarm.id], timestamp: alarmSince[alarm.id] }, alarm));
const count = (state) => map.sites.filter((s) => s.state === state).length;
summary.textContent = [count('alarm') + ' in alarm', count('warning') + ' warning', count('offline') + ' offline'].join(' · ');
}
const acked = {};
banner.addEventListener('acknowledge', function (event) {
acked[event.detail.alarm.id] = true;
publish();
});
/* ── The station on the right ───────────────────────────────────────── */
const symbols = {};
function openStation(site) {
selected = site;
stationName.textContent = site.name;
stationKind.textContent = site.kind + (site.offline ? ' · no data' : '');
pumpsHost.innerHTML = '';
cardsHost.innerHTML = '';
Object.keys(symbols).forEach((k) => delete symbols[k]);
if (!site.pumps.length) {
pumpsHost.innerHTML = '<p class="ps-none">A reservoir: no pumps of its own. The level is its outlet\'s supply.</p>';
}
site.pumps.forEach(function (p) {
const symbol = document.createElement('smart-mimic-symbol'),
card = document.createElement('smart-asset-card');
symbol.symbol = 'pump';
symbol.label = p.id;
symbol.labelPosition = 'bottom';
pumpsHost.appendChild(symbol);
symbols[p.id] = symbol;
Object.assign(card, { compact: true, readOnly: true, name: site.name + ' ' + p.id, assetId: site.id.toUpperCase() + '-' + p.id, serviceInterval: 2000 });
cardsHost.appendChild(card);
symbols[p.id].card = card;
});
trend.clear();
trend.pens = [
{ field: 'pressure', label: 'Discharge pressure', unit: 'bar', min: 0, max: 6, precision: 2 },
{ field: 'flow', label: 'Flow', unit: 'm³/h', min: 0, max: 500, precision: 0 }
];
const now = Date.now(),
history = [];
for (let t = now - 10 * MIN; t <= now; t += 5000) {
const tripped = site.pumps.some((p) => p.fault) && t > now - 6 * MIN;
history.push({ timestamp: t, pressure: site.offline ? null : Math.max(0, (tripped ? site.pressure : site.pressure || 3.8) + Math.sin(t / 40000) * 0.08), flow: site.offline ? null : Math.max(0, (tripped ? site.flow : site.flow || 120) + Math.sin(t / 55000) * 6) });
}
trend.pushMany(history);
showStation();
}
function showStation() {
const site = selected;
well.value = Math.round(site.level);
well.quality = site.offline ? 'stale' : 'good';
tilePressure.value = site.offline ? null : site.pressure;
tilePressure.state = site.offline ? 'unknown' : site.pumps.length && site.pressure < 1.5 && !site.maintenance ? 'critical' : 'neutral';
tileFlow.value = site.offline ? null : Math.round(site.flow);
tileFlow.state = site.offline ? 'unknown' : 'neutral';
site.pumps.forEach(function (p) {
const symbol = symbols[p.id];
if (!symbol) {
return;
}
symbol.state = site.offline ? 'unknown' : p.fault ? 'fault' : site.maintenance ? 'maintenance' : p.running ? 'running' : 'stopped';
symbol.value = p.fault ? 'TRIP' : p.running ? Math.round(site.flow / Math.max(1, site.pumps.filter((q) => q.running).length)) + ' m³/h' : '';
Object.assign(symbol.card, {
state: site.offline ? 'offline' : p.fault ? 'fault' : site.maintenance ? 'maintenance' : p.running ? 'running' : 'stopped',
runHours: Math.round(p.hours),
lastServiceHours: p.lastService
});
});
}
map.addEventListener('siteSelect', function (event) {
openStation(SITES.find((s) => s.id === event.detail.id));
});
map.addEventListener('siteOpen', function (event) {
openStation(SITES.find((s) => s.id === event.detail.id));
document.querySelector('.ps-station-panel').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
});
/* ── The feed ───────────────────────────────────────────────────────── */
function tick() {
SITES.forEach(function (site) {
if (site.offline || site.maintenance) {
return;
}
const running = site.pumps.filter((p) => p.running && !p.fault).length;
if (site.pumps.length) {
const target = running === 0 ? 0.6 : running === 1 && site.pumps.length > 1 && site.pumps.some((p) => p.fault) ? 0.8 : site.id === 'ps-mill' ? 3.9 : site.id === 'wtp' ? 4.6 : 3.4 + running * 0.3;
site.pressure += (target - site.pressure) * 0.25 + (Math.random() - 0.5) * 0.05;
site.flow = Math.max(0, site.flow + ((running * 60 + (site.id === 'wtp' ? 290 : 20)) - site.flow) * 0.2 + (Math.random() - 0.5) * 4);
site.pumps.forEach((p) => { if (p.running && !p.fault) { p.hours += 2 / 3600 * 600; } });
}
site.level = Math.max(5, Math.min(95, site.level + (Math.random() - 0.5) * 0.8));
});
const site = selected;
trend.push({ timestamp: Date.now(), pressure: site.offline ? null : site.pressure, flow: site.offline ? null : site.flow });
publish();
showStation();
}
tripButton.addEventListener('click', function () {
const east = SITES.find((s) => s.id === 'ps-east');
if (east.pumps[0].fault) {
east.pumps.forEach((p) => { p.fault = false; p.running = true; delete p.trip; });
tripButton.textContent = 'Trip PS East';
}
else {
east.pumps.forEach((p) => { p.fault = true; p.running = false; p.trip = 'Tripped on low suction pressure'; });
tripButton.textContent = 'Reset PS East';
}
tick();
});
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' }); };
time();
setInterval(time, 15000);
publish();
openStation(selected);
setInterval(tick, 2000);
};
styles.css
/* Pump stations. The map is the screen: the station beside it is what the supervisor opened,
and the alarm strip under both is the network's. */
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;
}
.ps-body {
display: grid;
grid-template-columns: minmax(0, 1.7fr) minmax(360px, 1fr);
grid-template-areas: "map station" "alarms alarms";
gap: 12px;
}
@media (max-width: 1100px) {
.ps-body {
grid-template-columns: 1fr;
grid-template-areas: "map" "station" "alarms";
}
}
.ps-map-panel { grid-area: map; }
.ps-station-panel { grid-area: station; }
.ps-alarms { grid-area: alarms; }
.ps-hint {
font-size: 11px;
font-weight: 400;
text-transform: none;
letter-spacing: 0;
color: var(--smart-text-secondary-color);
}
smart-site-map {
width: 100%;
}
/* the network's own picture, in the theme's colours and muted, so the sites are what stands out */
.region .lake,
.region .river {
fill: none;
stroke: color-mix(in srgb, var(--smart-primary) 26%, var(--smart-surface));
stroke-width: 26;
stroke-linecap: round;
}
.region .lake {
fill: color-mix(in srgb, var(--smart-primary) 26%, var(--smart-surface));
stroke: none;
}
.region .river.thin {
stroke-width: 12;
}
.region .town {
fill: color-mix(in srgb, var(--smart-text-color) 7%, var(--smart-surface));
}
/* the trunk mains between the sites */
.region .main {
fill: none;
stroke: var(--smart-border);
stroke-width: 6;
stroke-dasharray: 18 10;
stroke-linecap: round;
}
.region text {
fill: var(--smart-text-tertiary-color);
font-size: 28px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.ps-station {
display: flex;
flex-direction: column;
gap: 12px;
}
.ps-mimic {
display: flex;
align-items: flex-end;
gap: 24px;
min-height: 150px;
}
.ps-mimic smart-tank {
width: 90px;
height: 150px;
}
.ps-pumps {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 18px;
}
.ps-pumps smart-mimic-symbol {
width: 72px;
height: 96px;
}
.ps-none {
margin: 0;
max-width: 32ch;
font-size: 12px;
color: var(--smart-text-secondary-color);
}
.ps-tiles {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.ps-station smart-trend {
width: 100%;
height: 200px;
}
.ps-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 8px;
}
.ps-cards smart-asset-card {
width: auto;
}
smart-alarm-banner {
width: 100%;
}