Operator screen
Trend
A historian trend with multiple pens, a cursor and history loaded on demand.
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>Trend Screen - 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.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">
<header class="hmi-header">
<div class="hmi-identity">
<span class="hmi-plant">TREND</span>
<span class="hmi-area">Area 2 · historian</span>
</div>
<div class="hmi-toolbar">
<label class="toolbar-field">
<span>Window</span>
<smart-drop-down-list id="windowSelect" selected-indexes="[1]"></smart-drop-down-list>
</label>
<label class="toolbar-field">
<span>From</span>
<smart-date-time-picker id="fromPicker" calendar-button drop-down-display-mode="default"></smart-date-time-picker>
</label>
<button class="hmi-button" id="liveToggle" type="button" data-live="true">Live</button>
<button class="hmi-theme-toggle" id="themeToggle" type="button">Light</button>
</div>
</header>
<section class="hmi-body">
<aside class="hmi-panel hmi-panel-pens">
<div class="hmi-panel-header"><span>Pens</span></div>
<div class="hmi-panel-content">
<ul class="pen-list" id="penList"></ul>
</div>
<div class="hmi-panel-header hmi-panel-header-sub"><span>Cursor readout</span></div>
<div class="hmi-panel-content">
<ul class="readout-list" id="readoutList"></ul>
</div>
</aside>
<div class="hmi-panel hmi-panel-chart">
<div class="hmi-panel-header">
<span id="chartTitle">Process values</span>
<span class="hmi-panel-note" id="chartNote">1 s resolution</span>
</div>
<div class="hmi-panel-content">
<smart-chart id="trendChart"></smart-chart>
</div>
</div>
</section>
<footer class="hmi-statusbar">
<span id="statusRange">—</span>
<span id="statusCount">—</span>
<span class="status-spacer"></span>
<span id="statusMode">Live</span>
</footer>
</div>
<!-- scripts -->
<script type="module" src="node_modules/smart-industrial/source/smart.industrial.js"></script>
<script type="module" src="node_modules/smart-webcomponents/source/modules/smart.chart.js"></script>
<script type="module" src="node_modules/smart-webcomponents/source/modules/smart.dropdownlist.js"></script>
<script type="module" src="node_modules/smart-webcomponents/source/modules/smart.datetimepicker.js"></script>
<script type="text/javascript" src="data.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" />
/* Trend / historian screen.
*
* Pens carry different engineering units, so each is plotted as a percentage
* of its own range - the pen scaling a historian trend uses - while the pen
* list and cursor readout show engineering values. */
const PENS = HistorianData.pens;
const WINDOWS = [
{ label: 'Last 5 minutes', samples: 300, interval: 1000 },
{ label: 'Last 15 minutes', samples: 450, interval: 2000 },
{ label: 'Last hour', samples: 360, interval: 10000 },
{ label: 'Last 8 hours', samples: 480, interval: 60000 }
];
let activeWindow = WINDOWS[1],
live = true,
liveTimer = null,
rows = [];
const palette = Smart.Industrial.palette;
function scale(pen, value) {
return (value - pen.min) / (pen.max - pen.min) * 100;
}
/* The chart is fed scaled copies so the raw engineering values stay available
for the readout column. */
const chartData = [];
function toChartRow(record) {
/* The date axis wants Date instances, not epoch numbers. */
const scaled = { timestamp: new Date(record.timestamp) };
for (let i = 0; i < PENS.length; i++) {
scaled[PENS[i].tag] = scale(PENS[i], record[PENS[i].tag]);
}
return scaled;
}
window.Smart('#trendChart', class {
get properties() {
return {
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 },
dataSource: chartData,
xAxis: {
dataField: 'timestamp',
type: 'date',
baseUnit: 'minute',
valuesOnTicks: false,
gridLines: { visible: true, dashStyle: '2,2' },
labels: { angle: 0 },
formatFunction: (value) => value instanceof Date
? value.toLocaleTimeString('en-GB', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' })
: value
},
seriesGroups: [
{
type: 'line',
valueAxis: {
title: { text: '% of range' },
minValue: 0,
maxValue: 100,
unitInterval: 20
},
series: PENS.map((pen) => ({
dataField: pen.tag,
displayText: pen.tag,
lineWidth: 2,
symbolSize: 0
}))
}
]
};
}
});
window.onload = function () {
const chart = document.getElementById('trendChart'),
penList = document.getElementById('penList'),
readoutList = document.getElementById('readoutList'),
windowSelect = document.getElementById('windowSelect'),
fromPicker = document.getElementById('fromPicker'),
liveToggle = document.getElementById('liveToggle'),
themeToggle = document.getElementById('themeToggle'),
chartNote = document.getElementById('chartNote'),
statusRange = document.getElementById('statusRange'),
statusCount = document.getElementById('statusCount'),
statusMode = document.getElementById('statusMode');
const hidden = {};
windowSelect.dataSource = WINDOWS.map((w) => w.label);
/* ── Theme ───────────────────────────────────────────────────────── */
function penColors() {
return document.body.getAttribute('theme') === 'industrial-dark'
? palette.seriesDark
: palette.series;
}
function applyTheme(theme) {
document.body.setAttribute('theme', theme);
const elements = document.querySelectorAll('smart-chart, smart-drop-down-list, smart-date-time-picker');
for (let i = 0; i < elements.length; i++) {
elements[i].theme = theme;
}
themeToggle.textContent = theme === 'industrial-dark' ? 'Light' : 'Dark';
paintSeries();
renderPens();
}
/* Series colours live in JavaScript, not CSS - repaint them on theme change
and whenever a pen is hidden, since hiding is done by clearing the line. */
function paintSeries() {
const colors = penColors(),
series = chart.seriesGroups[0].series;
for (let i = 0; i < series.length; i++) {
const color = hidden[PENS[i].tag] ? 'transparent' : colors[PENS[i].color];
series[i].color = color;
series[i].lineColor = color;
}
chart.refresh();
}
themeToggle.addEventListener('click', function () {
applyTheme(document.body.getAttribute('theme') === 'industrial-dark' ? 'industrial' : 'industrial-dark');
});
/* ── Pen list ────────────────────────────────────────────────────── */
function renderPens() {
const colors = penColors();
penList.innerHTML = PENS.map(function (pen) {
return '<li class="pen-row' + (hidden[pen.tag] ? ' pen-hidden' : '') + '" data-tag="' + pen.tag + '">' +
'<span class="pen-swatch" style="background:' + colors[pen.color] + '"></span>' +
'<span class="pen-tag">' + pen.tag + '</span>' +
'<span class="pen-name">' + pen.name + '</span>' +
'<span class="pen-range">' + pen.min + '-' + pen.max + ' ' + pen.unit + '</span>' +
'</li>';
}).join('');
}
penList.addEventListener('click', function (event) {
const row = event.target.closest('.pen-row');
if (!row) {
return;
}
const tag = row.getAttribute('data-tag');
hidden[tag] = !hidden[tag];
renderPens();
paintSeries();
});
/* ── Readout column: latest engineering value per pen ────────────── */
function renderReadout(record) {
readoutList.innerHTML = PENS.map(function (pen) {
const value = record[pen.tag],
percent = scale(pen, value);
let severity = 'normal';
if (percent > 92 || percent < 4) {
severity = 'critical';
}
else if (percent > 82 || percent < 12) {
severity = 'warning';
}
return '<li class="readout-row">' +
'<span class="readout-tag">' + pen.tag + '</span>' +
'<span class="readout-value severity-' + severity + '">' +
value.toFixed(pen.max > 1000 ? 0 : pen.max > 20 ? 1 : 2) +
'<small>' + pen.unit + '</small></span>' +
'</li>';
}).join('');
}
/* ── Data loading ────────────────────────────────────────────────── */
function updateStatus() {
if (!rows.length) {
return;
}
const first = new Date(rows[0].timestamp),
last = new Date(rows[rows.length - 1].timestamp);
statusRange.textContent = first.toLocaleTimeString('en-GB', { hour12: false }) + ' → ' + last.toLocaleTimeString('en-GB', { hour12: false });
statusCount.textContent = rows.length + ' samples · ' + PENS.length + ' pens';
}
function load() {
rows = HistorianData.history(activeWindow.samples, activeWindow.interval);
chartData.length = 0;
for (let i = 0; i < rows.length; i++) {
chartData.push(toChartRow(rows[i]));
}
fromPicker.value = new Date(rows[0].timestamp);
chartNote.textContent = (activeWindow.interval / 1000) + ' s resolution';
renderReadout(rows[rows.length - 1]);
updateStatus();
chart.update();
}
function append(record) {
rows.push(record);
chartData.push(toChartRow(record));
while (rows.length > activeWindow.samples) {
rows.shift();
chartData.shift();
}
renderReadout(record);
updateStatus();
chart.update();
}
function setLive(on) {
live = on;
liveToggle.textContent = on ? 'Live' : 'Paused';
liveToggle.setAttribute('data-live', String(on));
statusMode.textContent = on ? 'Live' : 'Paused - historical view';
if (liveTimer) {
clearInterval(liveTimer);
liveTimer = null;
}
if (on) {
liveTimer = HistorianData.start(1000, append);
}
}
liveToggle.addEventListener('click', function () {
setLive(liveToggle.getAttribute('data-live') !== 'true');
});
windowSelect.addEventListener('change', function (event) {
const index = event.detail.index;
if (index === undefined || index === null) {
return;
}
activeWindow = WINDOWS[index];
load();
});
applyTheme('industrial-dark');
load();
setLive(true);
};
styles.css
/* Trend / historian screen. Colours come from the industrial theme tokens. */
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;
overflow: hidden;
}
.hmi-screen {
display: grid;
grid-template-rows: auto 1fr auto;
gap: 10px;
padding: 10px;
height: 100%;
box-sizing: border-box;
}
/* ── Header ──────────────────────────────────────────────────────────── */
.hmi-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 10px 14px;
background: var(--smart-industrial-panel-background);
border: 1px solid var(--smart-industrial-panel-border);
border-radius: 4px;
box-shadow: var(--smart-industrial-panel-shadow);
}
.hmi-identity {
display: flex;
flex-direction: column;
gap: 2px;
}
.hmi-plant {
font-size: 16px;
font-weight: 600;
letter-spacing: .08em;
}
.hmi-area {
color: var(--smart-text-tertiary-color);
font-size: 11px;
letter-spacing: .04em;
}
.hmi-toolbar {
display: flex;
align-items: flex-end;
gap: 14px;
flex-wrap: wrap;
}
.toolbar-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.toolbar-field > span {
color: var(--smart-text-tertiary-color);
font-size: 10px;
letter-spacing: .06em;
text-transform: uppercase;
}
.toolbar-field smart-drop-down-list {
width: 170px;
}
.toolbar-field smart-date-time-picker {
width: 210px;
}
.hmi-button,
.hmi-theme-toggle {
background: transparent;
border: 1px solid var(--smart-border);
border-radius: var(--smart-border-radius);
color: var(--smart-text-secondary-color);
cursor: pointer;
font-family: inherit;
font-size: 11px;
height: 30px;
letter-spacing: .06em;
padding: 0 14px;
text-transform: uppercase;
}
.hmi-button:hover,
.hmi-theme-toggle:hover {
background: var(--smart-ui-state-hover);
border-color: var(--smart-ui-state-border-hover);
color: var(--smart-ui-state-color-hover);
}
.hmi-button[data-live="true"] {
border-color: var(--smart-industrial-ok);
color: var(--smart-industrial-ok-text, var(--smart-industrial-ok));
}
.hmi-button[data-live="false"] {
border-color: var(--smart-industrial-alarm-warning);
color: var(--smart-industrial-alarm-warning-text, var(--smart-industrial-alarm-warning));
}
/* ── Body ────────────────────────────────────────────────────────────── */
.hmi-body {
display: grid;
grid-template-columns: minmax(260px, 320px) minmax(0, 1fr);
gap: 10px;
min-height: 0;
}
.hmi-panel {
display: flex;
flex-direction: column;
background: var(--smart-industrial-panel-background);
border: 1px solid var(--smart-industrial-panel-border);
border-radius: 4px;
box-shadow: var(--smart-industrial-panel-shadow);
overflow: hidden;
min-width: 0;
min-height: 0;
}
.hmi-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: var(--smart-industrial-panel-header-background);
border-bottom: 1px solid var(--smart-industrial-panel-border);
color: var(--smart-text-secondary-color);
font-size: 11.5px;
font-weight: 600;
letter-spacing: .06em;
text-transform: uppercase;
}
.hmi-panel-header-sub {
border-top: 1px solid var(--smart-industrial-panel-border);
}
.hmi-panel-note {
color: var(--smart-text-tertiary-color);
letter-spacing: 0;
text-transform: none;
}
.hmi-panel-content {
padding: 8px;
min-height: 0;
overflow: auto;
}
.hmi-panel-chart .hmi-panel-content {
flex: 1;
padding: 4px;
}
.hmi-panel-chart smart-chart {
width: 100%;
height: 100%;
min-height: 320px;
}
.hmi-panel-pens .hmi-panel-content:first-of-type {
flex: 1;
}
/* ── Pen list ────────────────────────────────────────────────────────── */
.pen-list,
.readout-list {
list-style: none;
margin: 0;
padding: 0;
}
.pen-row {
display: grid;
grid-template-columns: 12px 66px 1fr;
align-items: center;
gap: 8px;
padding: 8px 6px;
border-bottom: 1px solid var(--smart-border);
cursor: pointer;
}
.pen-row:last-child {
border-bottom: none;
}
.pen-row:hover {
background: var(--smart-ui-state-hover);
}
.pen-hidden {
opacity: .4;
}
.pen-swatch {
width: 14px;
height: 4px;
}
.pen-tag {
font-family: Consolas, "Courier New", monospace;
font-size: 11px;
color: var(--smart-text-secondary-color);
}
.pen-name {
color: var(--smart-text-color);
}
.pen-range {
grid-column: 2 / -1;
color: var(--smart-text-tertiary-color);
font-size: 11px;
}
/* ── Cursor readout ──────────────────────────────────────────────────── */
.readout-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
padding: 6px;
border-bottom: 1px solid var(--smart-border);
}
.readout-row:last-child {
border-bottom: none;
}
.readout-tag {
font-family: Consolas, "Courier New", monospace;
font-size: 11px;
color: var(--smart-text-tertiary-color);
}
.readout-value {
font-family: Consolas, "Courier New", monospace;
font-size: 17px;
}
.readout-value small {
font-size: 10px;
margin-left: 4px;
color: var(--smart-text-tertiary-color);
}
.severity-warning {
color: var(--smart-industrial-alarm-warning-text, var(--smart-industrial-alarm-warning));
}
.severity-critical {
color: var(--smart-industrial-alarm-critical-text, var(--smart-industrial-alarm-critical));
}
/* ── Status bar ──────────────────────────────────────────────────────── */
.hmi-statusbar {
display: flex;
align-items: center;
gap: 20px;
padding: 7px 14px;
background: var(--smart-industrial-panel-header-background);
border: 1px solid var(--smart-industrial-panel-border);
color: var(--smart-text-tertiary-color);
font-size: 11px;
border-radius: 4px;
box-shadow: var(--smart-industrial-panel-shadow);
}
.status-spacer {
flex: 1;
}
/* ── Responsive ──────────────────────────────────────────────────────── */
@media (max-width: 1000px) {
body.viewport {
overflow: auto;
}
.hmi-body {
grid-template-columns: 1fr;
}
}