Component demo · Operator screens and process graphics
Faceplate
Faceplate is a control loop faceplate that shows the process value, the setpoint and the controller output of a loop together with its mode.
index.htm as it is written with the smart-industrial package
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
<head>
<title>Overview - Faceplate Custom Element | Javascript UI Components | htmlelements</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="styles.css" />
</head>
<body class="viewport" theme="industrial">
<div class="fp-demo">
<header class="fp-head">
<h2>Control loop faceplate</h2>
<button class="fp-button" id="themeToggle" type="button">Dark</button>
</header>
<p class="fp-note">
Smart.Faceplate shows the three values of a control loop together: the process value, the setpoint and the controller output, with the mode of the loop. It is the component an operator opens to change a setpoint or an output.
</p>
<h3>The loops of a unit</h3>
<div class="fp-row" id="loopRow"></div>
<p class="fp-note" id="log">Nothing requested yet.</p>
<h3>Requests and confirmation</h3>
<p class="fp-note">
<code>requestSetpoint()</code> raises an event and does not change the value. The application sends the write to the control system, and the faceplate updates when the new value is reported. Until then the requested value is shown next to the confirmed value, for example <code>430.0 → 445.0</code>, and the marker on the bar is drawn hollow.
</p>
<p class="fp-note">
This confirmation step is the difference between a faceplate and a slider. If the write is rejected by an interlock or the connection is down, the faceplate keeps showing the confirmed value and the request is dropped after <code>pendingTimeout</code>.
</p>
<div class="fp-controls">
<button class="fp-button" id="slowLoop" type="button">Answer slowly (2 s)</button>
<button class="fp-button" id="refuseLoop" type="button">Refuse the next write</button>
<span class="fp-hint" id="ackNote">The first loop confirms writes immediately.</span>
</div>
<h3>Quality</h3>
<p class="fp-note">
A reading whose <code>quality</code> is not <code>good</code> is marked as such, in the quality badge and in <code>aria-valuetext</code>, so a stale value is not presented as a current one.
</p>
<div class="fp-row">
<smart-faceplate id="staleLoop" tag="TIC-204" description="Jacket temperature"
unit="°C" min="0" max="150" process-value="84.2" setpoint="85"
output="41" mode="auto" quality="stale" precision-digits="1"></smart-faceplate>
<smart-faceplate id="badLoop" tag="PIC-330" description="Header pressure"
unit="bar" min="0" max="12" process-value="6.3" setpoint="6.2"
output="55" mode="manual" quality="bad" precision-digits="2"></smart-faceplate>
<smart-faceplate id="oosLoop" tag="LIC-118" description="Surge drum level"
unit="%" min="0" max="100" process-value="47" setpoint="50"
output="0" mode="outOfService"></smart-faceplate>
</div>
<h3>Compact mode</h3>
<p class="fp-note">
The <code>compact</code> property hides the trend, the description and the column captions, for a strip of loops along the bottom of an overview screen.
</p>
<div class="fp-row" id="compactRow"></div>
</div>
<!-- scripts -->
<script type="module" src="node_modules/smart-industrial/source/modules/smart.faceplate.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>
</body>
</html>
index.js
/// <reference path="../../../source/typescript/smart.elements.d.ts" />
/* Faceplate overview.
*
* The important thing this demo shows is what the host has to do, because the faceplate
* deliberately does not do it: every request arrives as an event, the page decides, and
* only then does it write the value back. The three buttons let you watch the same
* request confirmed immediately, confirmed slowly, and refused - which is the case a
* slider cannot express at all. */
const LOOPS = [
{ tag: 'FIC-101', description: 'Feed flow', unit: 't/h', min: 0, max: 600,
pv: 412, sp: 430, op: 62, mode: 'auto', digits: 0,
limits: { hiHi: 560, hi: 500, lo: 300, loLo: 240 }, gain: 0.35, noise: 4 },
{ tag: 'TIC-102', description: 'Reactor temperature', unit: '°C', min: 0, max: 200,
pv: 148.6, sp: 150, op: 47, mode: 'cascade', digits: 1,
limits: { hiHi: 185, hi: 170, lo: 120, loLo: 100 }, gain: 0.22, noise: 0.6,
modes: ['auto', 'manual', 'cascade'] },
{ tag: 'LIC-103', description: 'Surge drum level', unit: '%', min: 0, max: 100,
pv: 62.4, sp: 60, op: 38, mode: 'manual', digits: 1,
limits: { hiHi: 90, hi: 80, lo: 25, loLo: 15 }, gain: 0.18, noise: 0.5,
clamp: { low: 20, high: 80 } }
];
function el(id) {
return document.getElementById(id);
}
window.onload = function () {
const row = el('loopRow'),
compactRow = el('compactRow'),
state = [];
let answerDelay = 0,
refuseNext = false;
LOOPS.forEach(function (loop, index) {
const plate = document.createElement('smart-faceplate');
plate.tag = loop.tag;
plate.description = loop.description;
plate.unit = loop.unit;
plate.min = loop.min;
plate.max = loop.max;
plate.precisionDigits = loop.digits;
plate.processValue = loop.pv;
plate.setpoint = loop.sp;
plate.output = loop.op;
plate.mode = loop.mode;
plate.availableModes = loop.modes || ['auto', 'manual'];
plate.alarmLimits = loop.limits;
plate.interactive = true;
plate.showTrend = true;
if (loop.clamp) {
plate.outputLimits = loop.clamp;
}
row.appendChild(plate);
state.push({ plate: plate, loop: loop });
//The same loops again, without the detail, as an overview strip would carry them.
const small = document.createElement('smart-faceplate');
small.tag = loop.tag;
small.unit = loop.unit;
small.min = loop.min;
small.max = loop.max;
small.precisionDigits = loop.digits;
small.processValue = loop.pv;
small.setpoint = loop.sp;
small.output = loop.op;
small.mode = loop.mode;
small.alarmLimits = loop.limits;
small.compact = true;
compactRow.appendChild(small);
state[index].small = small;
});
/* This is the control layer, standing in for a DCS. A request is a request: it can be
honoured now, honoured later, or refused - and the faceplate shows all three
correctly because it never assumed the first one. */
function confirm(plate, property, value) {
if (refuseNext) {
refuseNext = false;
el('ackNote').textContent = 'That write was refused. The request stays pending ' +
'until it times out, and the loop never moves.';
el('refuseLoop').textContent = 'Refuse the next write';
return;
}
if (answerDelay) {
setTimeout(function () {
plate[property] = value;
}, answerDelay);
return;
}
plate[property] = value;
}
state.forEach(function (entry) {
const plate = entry.plate;
plate.addEventListener('setpointChange', function (event) {
el('log').textContent = event.detail.tag + ': setpoint requested ' +
Number(event.detail.value).toFixed(entry.loop.digits);
confirm(plate, 'setpoint', event.detail.value);
});
plate.addEventListener('outputChange', function (event) {
el('log').textContent = event.detail.tag + ': output requested ' +
Number(event.detail.value).toFixed(1) + '%';
confirm(plate, 'output', event.detail.value);
});
plate.addEventListener('modeChange', function (event) {
el('log').textContent = event.detail.tag + ': mode requested ' + event.detail.value;
confirm(plate, 'mode', event.detail.value);
});
plate.addEventListener('writeTimeout', function (event) {
el('log').textContent = plate.tag + ': no answer for ' + event.detail.property +
' - the request was dropped and the loop was never told.';
});
plate.addEventListener('alarmStateChange', function (event) {
if (event.detail.state !== 'normal') {
el('log').textContent = plate.tag + ': ' + event.detail.state + ' alarm';
}
});
});
/* A first-order approach to setpoint, which is enough to make the columns behave the
way a loop behaves - the process value chases the setpoint, and lags. */
setInterval(function () {
state.forEach(function (entry) {
const loop = entry.loop,
plate = entry.plate,
sp = Number(plate.setpoint),
pv = Number(plate.processValue);
if (isNaN(sp) || isNaN(pv)) {
return;
}
const next = plate.mode === 'outOfService' ? pv :
pv + (sp - pv) * loop.gain + (Math.random() - 0.5) * loop.noise;
plate.processValue = next;
entry.small.processValue = next;
entry.small.setpoint = plate.setpoint;
entry.small.output = plate.output;
entry.small.mode = plate.mode;
});
}, 1000);
el('slowLoop').addEventListener('click', function () {
answerDelay = answerDelay ? 0 : 2000;
this.textContent = answerDelay ? 'Answer immediately' : 'Answer slowly (2 s)';
el('ackNote').textContent = answerDelay ?
'Writes now take two seconds. Change a setpoint and watch the request sit ' +
'beside the confirmed value until the controller answers.' :
'The first loop confirms writes immediately.';
});
el('refuseLoop').addEventListener('click', function () {
refuseNext = !refuseNext;
this.textContent = refuseNext ? 'Armed - next write is refused' : 'Refuse the next write';
el('ackNote').textContent = refuseNext ?
'The next request will be dropped on the floor, as an interlock would.' :
'The first loop confirms writes immediately.';
});
const toggle = el('themeToggle');
toggle.addEventListener('click', function () {
const dark = document.body.getAttribute('theme') === 'industrial-dark';
document.body.setAttribute('theme', dark ? 'industrial' : 'industrial-dark');
toggle.textContent = dark ? 'Dark' : 'Light';
});
};
styles.css
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;
margin: 0;
padding: 16px;
}
.fp-demo {
max-width: 1100px;
}
.fp-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
}
.fp-demo h2 {
font-size: 17px;
font-weight: 600;
margin: 0 0 10px;
}
.fp-demo h3 {
font-size: 11px;
font-weight: 600;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--smart-text-tertiary-color);
margin: 26px 0 10px;
}
.fp-note {
font-size: 12px;
line-height: 1.6;
color: var(--smart-text-secondary-color);
margin: 0 0 14px;
max-width: 100ch;
}
.fp-note code {
font-family: Consolas, Monaco, monospace;
font-size: 11.5px;
color: var(--smart-industrial-accent-text);
}
/* Faceplates sit side by side on a real screen and wrap rather than shrink - a
faceplate narrower than its columns stops being readable at all. */
.fp-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 14px;
align-items: stretch;
}
.fp-controls {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 14px;
}
.fp-hint {
font-size: 11.5px;
color: var(--smart-text-tertiary-color);
}
.fp-button {
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;
}
.fp-button:hover {
border-color: var(--smart-border-hover);
color: var(--smart-text-color);
}
.fp-button:focus-visible {
outline: 2px solid var(--smart-industrial-focus);
outline-offset: 2px;
box-shadow: 0 0 0 4px var(--smart-industrial-focus-halo);
}
#log {
font-family: Consolas, Monaco, monospace;
font-size: 11.5px;
background: var(--smart-industrial-panel-background, var(--smart-surface));
border: 1px solid var(--smart-industrial-panel-border, var(--smart-border));
border-radius: 3px;
padding: 8px 10px;
}
The same demo in Angular, generated from the HTML demo. The frameworks guide has the project setup.
angular/app.component.html
<div class="angular-root" theme="industrial">
<div class="fp-demo">
<header class="fp-head">
<h2>Control loop faceplate</h2>
<button class="fp-button" id="themeToggle" type="button">Dark</button>
</header>
<p class="fp-note">
Smart.Faceplate shows the three values of a control loop together: the process value, the setpoint and the controller output, with the mode of the loop. It is the component an operator opens to change a setpoint or an output.
</p>
<h3>The loops of a unit</h3>
<div class="fp-row" id="loopRow"></div>
<p class="fp-note" id="log">Nothing requested yet.</p>
<h3>Requests and confirmation</h3>
<p class="fp-note">
<code>requestSetpoint()</code> raises an event and does not change the value. The application sends the write to the control system, and the faceplate updates when the new value is reported. Until then the requested value is shown next to the confirmed value, for example <code>430.0 → 445.0</code>, and the marker on the bar is drawn hollow.
</p>
<p class="fp-note">
This confirmation step is the difference between a faceplate and a slider. If the write is rejected by an interlock or the connection is down, the faceplate keeps showing the confirmed value and the request is dropped after <code>pendingTimeout</code>.
</p>
<div class="fp-controls">
<button class="fp-button" id="slowLoop" type="button">Answer slowly (2 s)</button>
<button class="fp-button" id="refuseLoop" type="button">Refuse the next write</button>
<span class="fp-hint" id="ackNote">The first loop confirms writes immediately.</span>
</div>
<h3>Quality</h3>
<p class="fp-note">
A reading whose <code>quality</code> is not <code>good</code> is marked as such, in the quality badge and in <code>aria-valuetext</code>, so a stale value is not presented as a current one.
</p>
<div class="fp-row">
<smart-faceplate id="staleLoop" tag="TIC-204" description="Jacket temperature"
unit="°C" [min]="0" [max]="150" [processValue]="84.2" [setpoint]="85" [output]="41" mode="auto" quality="stale" [precisionDigits]="1"></smart-faceplate>
<smart-faceplate id="badLoop" tag="PIC-330" description="Header pressure"
unit="bar" [min]="0" [max]="12" [processValue]="6.3" [setpoint]="6.2" [output]="55" mode="manual" quality="bad" [precisionDigits]="2"></smart-faceplate>
<smart-faceplate id="oosLoop" tag="LIC-118" description="Surge drum level"
unit="%" [min]="0" [max]="100" [processValue]="47" [setpoint]="50" [output]="0" mode="outOfService"></smart-faceplate>
</div>
<h3>Compact mode</h3>
<p class="fp-note">
The <code>compact</code> property hides the trend, the description and the column captions, for a strip of loops along the bottom of an overview screen.
</p>
<div class="fp-row" id="compactRow"></div>
</div>
</div>
angular/app.component.ts
import { Component, AfterViewInit, OnDestroy } from '@angular/core';
import { FaceplateModule } from 'smart-industrial/angular/faceplate';
import { runDemo } from './demo';
/**
* Smart.UI Industrial - Faceplate in Angular.
*
* The template is the HTML demo's markup: the elements are the wrappers, so what is
* written on them is checked against their inputs. demo.ts drives them the way the
* plant's data would, once every element in the view has rendered.
*/
@Component({
selector: 'app-root',
standalone: true,
imports: [FaceplateModule],
templateUrl: './app.component.html',
styleUrl: './app.component.css'
})
export class AppComponent implements AfterViewInit, OnDestroy {
private unmounted = false;
ngAfterViewInit(): void {
const root = document.querySelector('.angular-root') as HTMLElement,
pending: Promise<void>[] = [];
root.querySelectorAll('*').forEach((element: any) => {
if (typeof element.whenRendered === 'function' && !element.isRendered) {
pending.push(element.whenRendered());
}
});
Promise.all(pending).then(() => {
if (!this.unmounted) {
runDemo();
}
});
}
ngOnDestroy(): void {
this.unmounted = true;
}
}
angular/demo.ts
// @ts-nocheck
/* The HTML demo's script, as it was written: page code that feeds the elements, not a typed
API. The types are on the wrappers in app.component.ts. */
/* Faceplate overview.
*
* The important thing this demo shows is what the host has to do, because the faceplate
* deliberately does not do it: every request arrives as an event, the page decides, and
* only then does it write the value back. The three buttons let you watch the same
* request confirmed immediately, confirmed slowly, and refused - which is the case a
* slider cannot express at all. */
const LOOPS = [
{ tag: 'FIC-101', description: 'Feed flow', unit: 't/h', min: 0, max: 600,
pv: 412, sp: 430, op: 62, mode: 'auto', digits: 0,
limits: { hiHi: 560, hi: 500, lo: 300, loLo: 240 }, gain: 0.35, noise: 4 },
{ tag: 'TIC-102', description: 'Reactor temperature', unit: '°C', min: 0, max: 200,
pv: 148.6, sp: 150, op: 47, mode: 'cascade', digits: 1,
limits: { hiHi: 185, hi: 170, lo: 120, loLo: 100 }, gain: 0.22, noise: 0.6,
modes: ['auto', 'manual', 'cascade'] },
{ tag: 'LIC-103', description: 'Surge drum level', unit: '%', min: 0, max: 100,
pv: 62.4, sp: 60, op: 38, mode: 'manual', digits: 1,
limits: { hiHi: 90, hi: 80, lo: 25, loLo: 15 }, gain: 0.18, noise: 0.5,
clamp: { low: 20, high: 80 } }
];
function el(id) {
return document.getElementById(id);
}
export function runDemo(): void {
const row = el('loopRow'),
compactRow = el('compactRow'),
state = [];
let answerDelay = 0,
refuseNext = false;
LOOPS.forEach(function (loop, index) {
const plate = document.createElement('smart-faceplate');
plate.tag = loop.tag;
plate.description = loop.description;
plate.unit = loop.unit;
plate.min = loop.min;
plate.max = loop.max;
plate.precisionDigits = loop.digits;
plate.processValue = loop.pv;
plate.setpoint = loop.sp;
plate.output = loop.op;
plate.mode = loop.mode;
plate.availableModes = loop.modes || ['auto', 'manual'];
plate.alarmLimits = loop.limits;
plate.interactive = true;
plate.showTrend = true;
if (loop.clamp) {
plate.outputLimits = loop.clamp;
}
row.appendChild(plate);
state.push({ plate: plate, loop: loop });
//The same loops again, without the detail, as an overview strip would carry them.
const small = document.createElement('smart-faceplate');
small.tag = loop.tag;
small.unit = loop.unit;
small.min = loop.min;
small.max = loop.max;
small.precisionDigits = loop.digits;
small.processValue = loop.pv;
small.setpoint = loop.sp;
small.output = loop.op;
small.mode = loop.mode;
small.alarmLimits = loop.limits;
small.compact = true;
compactRow.appendChild(small);
state[index].small = small;
});
/* This is the control layer, standing in for a DCS. A request is a request: it can be
honoured now, honoured later, or refused - and the faceplate shows all three
correctly because it never assumed the first one. */
function confirm(plate, property, value) {
if (refuseNext) {
refuseNext = false;
el('ackNote').textContent = 'That write was refused. The request stays pending ' +
'until it times out, and the loop never moves.';
el('refuseLoop').textContent = 'Refuse the next write';
return;
}
if (answerDelay) {
setTimeout(function () {
plate[property] = value;
}, answerDelay);
return;
}
plate[property] = value;
}
state.forEach(function (entry) {
const plate = entry.plate;
plate.addEventListener('setpointChange', function (event) {
el('log').textContent = event.detail.tag + ': setpoint requested ' +
Number(event.detail.value).toFixed(entry.loop.digits);
confirm(plate, 'setpoint', event.detail.value);
});
plate.addEventListener('outputChange', function (event) {
el('log').textContent = event.detail.tag + ': output requested ' +
Number(event.detail.value).toFixed(1) + '%';
confirm(plate, 'output', event.detail.value);
});
plate.addEventListener('modeChange', function (event) {
el('log').textContent = event.detail.tag + ': mode requested ' + event.detail.value;
confirm(plate, 'mode', event.detail.value);
});
plate.addEventListener('writeTimeout', function (event) {
el('log').textContent = plate.tag + ': no answer for ' + event.detail.property +
' - the request was dropped and the loop was never told.';
});
plate.addEventListener('alarmStateChange', function (event) {
if (event.detail.state !== 'normal') {
el('log').textContent = plate.tag + ': ' + event.detail.state + ' alarm';
}
});
});
/* A first-order approach to setpoint, which is enough to make the columns behave the
way a loop behaves - the process value chases the setpoint, and lags. */
setInterval(function () {
state.forEach(function (entry) {
const loop = entry.loop,
plate = entry.plate,
sp = Number(plate.setpoint),
pv = Number(plate.processValue);
if (isNaN(sp) || isNaN(pv)) {
return;
}
const next = plate.mode === 'outOfService' ? pv :
pv + (sp - pv) * loop.gain + (Math.random() - 0.5) * loop.noise;
plate.processValue = next;
entry.small.processValue = next;
entry.small.setpoint = plate.setpoint;
entry.small.output = plate.output;
entry.small.mode = plate.mode;
});
}, 1000);
el('slowLoop').addEventListener('click', function () {
answerDelay = answerDelay ? 0 : 2000;
this.textContent = answerDelay ? 'Answer immediately' : 'Answer slowly (2 s)';
el('ackNote').textContent = answerDelay ?
'Writes now take two seconds. Change a setpoint and watch the request sit ' +
'beside the confirmed value until the controller answers.' :
'The first loop confirms writes immediately.';
});
el('refuseLoop').addEventListener('click', function () {
refuseNext = !refuseNext;
this.textContent = refuseNext ? 'Armed - next write is refused' : 'Refuse the next write';
el('ackNote').textContent = refuseNext ?
'The next request will be dropped on the floor, as an interlock would.' :
'The first loop confirms writes immediately.';
});
const toggle = el('themeToggle');
toggle.addEventListener('click', function () {
const dark = document.body.getAttribute('theme') === 'industrial-dark';
document.body.setAttribute('theme', dark ? 'industrial' : 'industrial-dark');
toggle.textContent = dark ? 'Dark' : 'Light';
});
}
angular/app.component.css
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;
margin: 0;
padding: 16px;
}
.fp-demo {
max-width: 1100px;
}
.fp-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
}
.fp-demo h2 {
font-size: 17px;
font-weight: 600;
margin: 0 0 10px;
}
.fp-demo h3 {
font-size: 11px;
font-weight: 600;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--smart-text-tertiary-color);
margin: 26px 0 10px;
}
.fp-note {
font-size: 12px;
line-height: 1.6;
color: var(--smart-text-secondary-color);
margin: 0 0 14px;
max-width: 100ch;
}
.fp-note code {
font-family: Consolas, Monaco, monospace;
font-size: 11.5px;
color: var(--smart-industrial-accent-text);
}
/* Faceplates sit side by side on a real screen and wrap rather than shrink - a
faceplate narrower than its columns stops being readable at all. */
.fp-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 14px;
align-items: stretch;
}
.fp-controls {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 14px;
}
.fp-hint {
font-size: 11.5px;
color: var(--smart-text-tertiary-color);
}
.fp-button {
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;
}
.fp-button:hover {
border-color: var(--smart-border-hover);
color: var(--smart-text-color);
}
.fp-button:focus-visible {
outline: 2px solid var(--smart-industrial-focus);
outline-offset: 2px;
box-shadow: 0 0 0 4px var(--smart-industrial-focus-halo);
}
#log {
font-family: Consolas, Monaco, monospace;
font-size: 11.5px;
background: var(--smart-industrial-panel-background, var(--smart-surface));
border: 1px solid var(--smart-industrial-panel-border, var(--smart-border));
border-radius: 3px;
padding: 8px 10px;
}
The same demo in React, generated from the HTML demo. The frameworks guide has the project setup.
react/App.jsx
import 'smart-industrial/source/styles/smart.default.css';
import 'smart-industrial/source/styles/smart.industrial-elements.css';
import 'smart-industrial/source/styles/smart.industrial.css';
import 'smart-industrial/source/styles/smart.industrial-dark.css';
import './App.css';
import React from 'react';
import { Faceplate } from 'smart-industrial/react/faceplate';
/* The demo logic is the HTML demo's: it feeds the elements the way a plant's data would. */
/* Faceplate overview.
*
* The important thing this demo shows is what the host has to do, because the faceplate
* deliberately does not do it: every request arrives as an event, the page decides, and
* only then does it write the value back. The three buttons let you watch the same
* request confirmed immediately, confirmed slowly, and refused - which is the case a
* slider cannot express at all. */
const LOOPS = [
{ tag: 'FIC-101', description: 'Feed flow', unit: 't/h', min: 0, max: 600,
pv: 412, sp: 430, op: 62, mode: 'auto', digits: 0,
limits: { hiHi: 560, hi: 500, lo: 300, loLo: 240 }, gain: 0.35, noise: 4 },
{ tag: 'TIC-102', description: 'Reactor temperature', unit: '°C', min: 0, max: 200,
pv: 148.6, sp: 150, op: 47, mode: 'cascade', digits: 1,
limits: { hiHi: 185, hi: 170, lo: 120, loLo: 100 }, gain: 0.22, noise: 0.6,
modes: ['auto', 'manual', 'cascade'] },
{ tag: 'LIC-103', description: 'Surge drum level', unit: '%', min: 0, max: 100,
pv: 62.4, sp: 60, op: 38, mode: 'manual', digits: 1,
limits: { hiHi: 90, hi: 80, lo: 25, loLo: 15 }, gain: 0.18, noise: 0.5,
clamp: { low: 20, high: 80 } }
];
function el(id) {
return document.getElementById(id);
}
const App = () => {
const root = React.useRef(null);
React.useEffect(() => {
/* The elements render after React has mounted them, and the demo's logic - written
for a page's load event - reads their parts, so it waits for every element in the
tree to have rendered first. */
const pending = Array.from(root.current.querySelectorAll('*'))
.filter((element) => typeof element.whenRendered === 'function' && !element.isRendered)
.map((element) => element.whenRendered());
let unmounted = false;
Promise.all(pending).then(() => {
if (unmounted) {
return;
}
const row = el('loopRow'),
compactRow = el('compactRow'),
state = [];
let answerDelay = 0,
refuseNext = false;
LOOPS.forEach(function (loop, index) {
const plate = document.createElement('smart-faceplate');
plate.tag = loop.tag;
plate.description = loop.description;
plate.unit = loop.unit;
plate.min = loop.min;
plate.max = loop.max;
plate.precisionDigits = loop.digits;
plate.processValue = loop.pv;
plate.setpoint = loop.sp;
plate.output = loop.op;
plate.mode = loop.mode;
plate.availableModes = loop.modes || ['auto', 'manual'];
plate.alarmLimits = loop.limits;
plate.interactive = true;
plate.showTrend = true;
if (loop.clamp) {
plate.outputLimits = loop.clamp;
}
row.appendChild(plate);
state.push({ plate: plate, loop: loop });
//The same loops again, without the detail, as an overview strip would carry them.
const small = document.createElement('smart-faceplate');
small.tag = loop.tag;
small.unit = loop.unit;
small.min = loop.min;
small.max = loop.max;
small.precisionDigits = loop.digits;
small.processValue = loop.pv;
small.setpoint = loop.sp;
small.output = loop.op;
small.mode = loop.mode;
small.alarmLimits = loop.limits;
small.compact = true;
compactRow.appendChild(small);
state[index].small = small;
});
/* This is the control layer, standing in for a DCS. A request is a request: it can be
honoured now, honoured later, or refused - and the faceplate shows all three
correctly because it never assumed the first one. */
function confirm(plate, property, value) {
if (refuseNext) {
refuseNext = false;
el('ackNote').textContent = 'That write was refused. The request stays pending ' +
'until it times out, and the loop never moves.';
el('refuseLoop').textContent = 'Refuse the next write';
return;
}
if (answerDelay) {
setTimeout(function () {
plate[property] = value;
}, answerDelay);
return;
}
plate[property] = value;
}
state.forEach(function (entry) {
const plate = entry.plate;
plate.addEventListener('setpointChange', function (event) {
el('log').textContent = event.detail.tag + ': setpoint requested ' +
Number(event.detail.value).toFixed(entry.loop.digits);
confirm(plate, 'setpoint', event.detail.value);
});
plate.addEventListener('outputChange', function (event) {
el('log').textContent = event.detail.tag + ': output requested ' +
Number(event.detail.value).toFixed(1) + '%';
confirm(plate, 'output', event.detail.value);
});
plate.addEventListener('modeChange', function (event) {
el('log').textContent = event.detail.tag + ': mode requested ' + event.detail.value;
confirm(plate, 'mode', event.detail.value);
});
plate.addEventListener('writeTimeout', function (event) {
el('log').textContent = plate.tag + ': no answer for ' + event.detail.property +
' - the request was dropped and the loop was never told.';
});
plate.addEventListener('alarmStateChange', function (event) {
if (event.detail.state !== 'normal') {
el('log').textContent = plate.tag + ': ' + event.detail.state + ' alarm';
}
});
});
/* A first-order approach to setpoint, which is enough to make the columns behave the
way a loop behaves - the process value chases the setpoint, and lags. */
setInterval(function () {
state.forEach(function (entry) {
const loop = entry.loop,
plate = entry.plate,
sp = Number(plate.setpoint),
pv = Number(plate.processValue);
if (isNaN(sp) || isNaN(pv)) {
return;
}
const next = plate.mode === 'outOfService' ? pv :
pv + (sp - pv) * loop.gain + (Math.random() - 0.5) * loop.noise;
plate.processValue = next;
entry.small.processValue = next;
entry.small.setpoint = plate.setpoint;
entry.small.output = plate.output;
entry.small.mode = plate.mode;
});
}, 1000);
el('slowLoop').addEventListener('click', function () {
answerDelay = answerDelay ? 0 : 2000;
this.textContent = answerDelay ? 'Answer immediately' : 'Answer slowly (2 s)';
el('ackNote').textContent = answerDelay ?
'Writes now take two seconds. Change a setpoint and watch the request sit ' +
'beside the confirmed value until the controller answers.' :
'The first loop confirms writes immediately.';
});
el('refuseLoop').addEventListener('click', function () {
refuseNext = !refuseNext;
this.textContent = refuseNext ? 'Armed - next write is refused' : 'Refuse the next write';
el('ackNote').textContent = refuseNext ?
'The next request will be dropped on the floor, as an interlock would.' :
'The first loop confirms writes immediately.';
});
const toggle = el('themeToggle');
toggle.addEventListener('click', function () {
const dark = document.body.getAttribute('theme') === 'industrial-dark';
document.body.setAttribute('theme', dark ? 'industrial' : 'industrial-dark');
toggle.textContent = dark ? 'Dark' : 'Light';
});
});
return () => {
unmounted = true;
};
}, []);
return (
<div className="react-root" ref={root} theme="industrial">
<div className="fp-demo">
<header className="fp-head">
<h2>Control loop faceplate</h2>
<button className="fp-button" id="themeToggle" type="button">Dark</button>
</header>
<p className="fp-note">Smart.Faceplate shows the three values of a control loop together: the process value, the setpoint and the controller output, with the mode of the loop. It is the component an operator opens to change a setpoint or an output.</p>
<h3>The loops of a unit</h3>
<div className="fp-row" id="loopRow"></div>
<p className="fp-note" id="log">Nothing requested yet.</p>
<h3>Requests and confirmation</h3>
<p className="fp-note"><code>requestSetpoint()</code> raises an event and does not change the value. The application sends the write to the control system, and the faceplate updates when the new value is reported. Until then the requested value is shown next to the confirmed value, for example <code>430.0 → 445.0</code>, and the marker on the bar is drawn hollow.</p>
<p className="fp-note">This confirmation step is the difference between a faceplate and a slider. If the write is rejected by an interlock or the connection is down, the faceplate keeps showing the confirmed value and the request is dropped after <code>pendingTimeout</code>.</p>
<div className="fp-controls">
<button className="fp-button" id="slowLoop" type="button">Answer slowly (2 s)</button>
<button className="fp-button" id="refuseLoop" type="button">Refuse the next write</button>
<span className="fp-hint" id="ackNote">The first loop confirms writes immediately.</span>
</div>
<h3>Quality</h3>
<p className="fp-note">A reading whose <code>quality</code> is not <code>good</code> is marked as such, in the quality badge and in <code>aria-valuetext</code>, so a stale value is not presented as a current one.</p>
<div className="fp-row">
<Faceplate id="staleLoop" tag="TIC-204" description="Jacket temperature" unit="°C" min={0} max={150} processValue={84.2} setpoint={85} output={41} mode="auto" quality="stale" precisionDigits={1}></Faceplate>
<Faceplate id="badLoop" tag="PIC-330" description="Header pressure" unit="bar" min={0} max={12} processValue={6.3} setpoint={6.2} output={55} mode="manual" quality="bad" precisionDigits={2}></Faceplate>
<Faceplate id="oosLoop" tag="LIC-118" description="Surge drum level" unit="%" min={0} max={100} processValue={47} setpoint={50} output={0} mode="outOfService"></Faceplate>
</div>
<h3>Compact mode</h3>
<p className="fp-note">The <code>compact</code> property hides the trend, the description and the column captions, for a strip of loops along the bottom of an overview screen.</p>
<div className="fp-row" id="compactRow"></div>
</div>
</div>
);
};
export default App;
react/App.css
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;
margin: 0;
padding: 16px;
}
.fp-demo {
max-width: 1100px;
}
.fp-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
}
.fp-demo h2 {
font-size: 17px;
font-weight: 600;
margin: 0 0 10px;
}
.fp-demo h3 {
font-size: 11px;
font-weight: 600;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--smart-text-tertiary-color);
margin: 26px 0 10px;
}
.fp-note {
font-size: 12px;
line-height: 1.6;
color: var(--smart-text-secondary-color);
margin: 0 0 14px;
max-width: 100ch;
}
.fp-note code {
font-family: Consolas, Monaco, monospace;
font-size: 11.5px;
color: var(--smart-industrial-accent-text);
}
/* Faceplates sit side by side on a real screen and wrap rather than shrink - a
faceplate narrower than its columns stops being readable at all. */
.fp-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 14px;
align-items: stretch;
}
.fp-controls {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 14px;
}
.fp-hint {
font-size: 11.5px;
color: var(--smart-text-tertiary-color);
}
.fp-button {
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;
}
.fp-button:hover {
border-color: var(--smart-border-hover);
color: var(--smart-text-color);
}
.fp-button:focus-visible {
outline: 2px solid var(--smart-industrial-focus);
outline-offset: 2px;
box-shadow: 0 0 0 4px var(--smart-industrial-focus-halo);
}
#log {
font-family: Consolas, Monaco, monospace;
font-size: 11.5px;
background: var(--smart-industrial-panel-background, var(--smart-surface));
border: 1px solid var(--smart-industrial-panel-border, var(--smart-border));
border-radius: 3px;
padding: 8px 10px;
}
The same demo in Vue, generated from the HTML demo. The frameworks guide has the project setup.
vue/App.vue
<template>
<div class="vue-root" theme="industrial" v-pre>
<div class="fp-demo">
<header class="fp-head">
<h2>Control loop faceplate</h2>
<button class="fp-button" id="themeToggle" type="button">Dark</button>
</header>
<p class="fp-note">
Smart.Faceplate shows the three values of a control loop together: the process value, the setpoint and the controller output, with the mode of the loop. It is the component an operator opens to change a setpoint or an output.
</p>
<h3>The loops of a unit</h3>
<div class="fp-row" id="loopRow"></div>
<p class="fp-note" id="log">Nothing requested yet.</p>
<h3>Requests and confirmation</h3>
<p class="fp-note">
<code>requestSetpoint()</code> raises an event and does not change the value. The application sends the write to the control system, and the faceplate updates when the new value is reported. Until then the requested value is shown next to the confirmed value, for example <code>430.0 → 445.0</code>, and the marker on the bar is drawn hollow.
</p>
<p class="fp-note">
This confirmation step is the difference between a faceplate and a slider. If the write is rejected by an interlock or the connection is down, the faceplate keeps showing the confirmed value and the request is dropped after <code>pendingTimeout</code>.
</p>
<div class="fp-controls">
<button class="fp-button" id="slowLoop" type="button">Answer slowly (2 s)</button>
<button class="fp-button" id="refuseLoop" type="button">Refuse the next write</button>
<span class="fp-hint" id="ackNote">The first loop confirms writes immediately.</span>
</div>
<h3>Quality</h3>
<p class="fp-note">
A reading whose <code>quality</code> is not <code>good</code> is marked as such, in the quality badge and in <code>aria-valuetext</code>, so a stale value is not presented as a current one.
</p>
<div class="fp-row">
<smart-faceplate id="staleLoop" tag="TIC-204" description="Jacket temperature"
unit="°C" min="0" max="150" process-value="84.2" setpoint="85"
output="41" mode="auto" quality="stale" precision-digits="1"></smart-faceplate>
<smart-faceplate id="badLoop" tag="PIC-330" description="Header pressure"
unit="bar" min="0" max="12" process-value="6.3" setpoint="6.2"
output="55" mode="manual" quality="bad" precision-digits="2"></smart-faceplate>
<smart-faceplate id="oosLoop" tag="LIC-118" description="Surge drum level"
unit="%" min="0" max="100" process-value="47" setpoint="50"
output="0" mode="outOfService"></smart-faceplate>
</div>
<h3>Compact mode</h3>
<p class="fp-note">
The <code>compact</code> property hides the trend, the description and the column captions, for a strip of loops along the bottom of an overview screen.
</p>
<div class="fp-row" id="compactRow"></div>
</div>
</div>
</template>
<script>
import { onMounted } from "vue";
import "smart-industrial/source/styles/smart.default.css";
import "smart-industrial/source/styles/smart.industrial-elements.css";
import "smart-industrial/source/styles/smart.industrial.css";
import "smart-industrial/source/styles/smart.industrial-dark.css";
import "smart-industrial/source/modules/smart.faceplate.js";
/* Faceplate overview.
*
* The important thing this demo shows is what the host has to do, because the faceplate
* deliberately does not do it: every request arrives as an event, the page decides, and
* only then does it write the value back. The three buttons let you watch the same
* request confirmed immediately, confirmed slowly, and refused - which is the case a
* slider cannot express at all. */
const LOOPS = [
{ tag: 'FIC-101', description: 'Feed flow', unit: 't/h', min: 0, max: 600,
pv: 412, sp: 430, op: 62, mode: 'auto', digits: 0,
limits: { hiHi: 560, hi: 500, lo: 300, loLo: 240 }, gain: 0.35, noise: 4 },
{ tag: 'TIC-102', description: 'Reactor temperature', unit: '°C', min: 0, max: 200,
pv: 148.6, sp: 150, op: 47, mode: 'cascade', digits: 1,
limits: { hiHi: 185, hi: 170, lo: 120, loLo: 100 }, gain: 0.22, noise: 0.6,
modes: ['auto', 'manual', 'cascade'] },
{ tag: 'LIC-103', description: 'Surge drum level', unit: '%', min: 0, max: 100,
pv: 62.4, sp: 60, op: 38, mode: 'manual', digits: 1,
limits: { hiHi: 90, hi: 80, lo: 25, loLo: 15 }, gain: 0.18, noise: 0.5,
clamp: { low: 20, high: 80 } }
];
function el(id) {
return document.getElementById(id);
}
export default {
name: "app",
setup() {
onMounted(() => {
/* The elements render after Vue has mounted them, and the demo's logic - written for
a page's load event - reads their parts, so it waits for every element in the tree
to have rendered first. */
const pending = Array.from(document.querySelector(".vue-root").querySelectorAll("*"))
.filter((element) => typeof element.whenRendered === "function" && !element.isRendered)
.map((element) => element.whenRendered());
Promise.all(pending).then(() => {
const row = el('loopRow'),
compactRow = el('compactRow'),
state = [];
let answerDelay = 0,
refuseNext = false;
LOOPS.forEach(function (loop, index) {
const plate = document.createElement('smart-faceplate');
plate.tag = loop.tag;
plate.description = loop.description;
plate.unit = loop.unit;
plate.min = loop.min;
plate.max = loop.max;
plate.precisionDigits = loop.digits;
plate.processValue = loop.pv;
plate.setpoint = loop.sp;
plate.output = loop.op;
plate.mode = loop.mode;
plate.availableModes = loop.modes || ['auto', 'manual'];
plate.alarmLimits = loop.limits;
plate.interactive = true;
plate.showTrend = true;
if (loop.clamp) {
plate.outputLimits = loop.clamp;
}
row.appendChild(plate);
state.push({ plate: plate, loop: loop });
//The same loops again, without the detail, as an overview strip would carry them.
const small = document.createElement('smart-faceplate');
small.tag = loop.tag;
small.unit = loop.unit;
small.min = loop.min;
small.max = loop.max;
small.precisionDigits = loop.digits;
small.processValue = loop.pv;
small.setpoint = loop.sp;
small.output = loop.op;
small.mode = loop.mode;
small.alarmLimits = loop.limits;
small.compact = true;
compactRow.appendChild(small);
state[index].small = small;
});
/* This is the control layer, standing in for a DCS. A request is a request: it can be
honoured now, honoured later, or refused - and the faceplate shows all three
correctly because it never assumed the first one. */
function confirm(plate, property, value) {
if (refuseNext) {
refuseNext = false;
el('ackNote').textContent = 'That write was refused. The request stays pending ' +
'until it times out, and the loop never moves.';
el('refuseLoop').textContent = 'Refuse the next write';
return;
}
if (answerDelay) {
setTimeout(function () {
plate[property] = value;
}, answerDelay);
return;
}
plate[property] = value;
}
state.forEach(function (entry) {
const plate = entry.plate;
plate.addEventListener('setpointChange', function (event) {
el('log').textContent = event.detail.tag + ': setpoint requested ' +
Number(event.detail.value).toFixed(entry.loop.digits);
confirm(plate, 'setpoint', event.detail.value);
});
plate.addEventListener('outputChange', function (event) {
el('log').textContent = event.detail.tag + ': output requested ' +
Number(event.detail.value).toFixed(1) + '%';
confirm(plate, 'output', event.detail.value);
});
plate.addEventListener('modeChange', function (event) {
el('log').textContent = event.detail.tag + ': mode requested ' + event.detail.value;
confirm(plate, 'mode', event.detail.value);
});
plate.addEventListener('writeTimeout', function (event) {
el('log').textContent = plate.tag + ': no answer for ' + event.detail.property +
' - the request was dropped and the loop was never told.';
});
plate.addEventListener('alarmStateChange', function (event) {
if (event.detail.state !== 'normal') {
el('log').textContent = plate.tag + ': ' + event.detail.state + ' alarm';
}
});
});
/* A first-order approach to setpoint, which is enough to make the columns behave the
way a loop behaves - the process value chases the setpoint, and lags. */
setInterval(function () {
state.forEach(function (entry) {
const loop = entry.loop,
plate = entry.plate,
sp = Number(plate.setpoint),
pv = Number(plate.processValue);
if (isNaN(sp) || isNaN(pv)) {
return;
}
const next = plate.mode === 'outOfService' ? pv :
pv + (sp - pv) * loop.gain + (Math.random() - 0.5) * loop.noise;
plate.processValue = next;
entry.small.processValue = next;
entry.small.setpoint = plate.setpoint;
entry.small.output = plate.output;
entry.small.mode = plate.mode;
});
}, 1000);
el('slowLoop').addEventListener('click', function () {
answerDelay = answerDelay ? 0 : 2000;
this.textContent = answerDelay ? 'Answer immediately' : 'Answer slowly (2 s)';
el('ackNote').textContent = answerDelay ?
'Writes now take two seconds. Change a setpoint and watch the request sit ' +
'beside the confirmed value until the controller answers.' :
'The first loop confirms writes immediately.';
});
el('refuseLoop').addEventListener('click', function () {
refuseNext = !refuseNext;
this.textContent = refuseNext ? 'Armed - next write is refused' : 'Refuse the next write';
el('ackNote').textContent = refuseNext ?
'The next request will be dropped on the floor, as an interlock would.' :
'The first loop confirms writes immediately.';
});
const toggle = el('themeToggle');
toggle.addEventListener('click', function () {
const dark = document.body.getAttribute('theme') === 'industrial-dark';
document.body.setAttribute('theme', dark ? 'industrial' : 'industrial-dark');
toggle.textContent = dark ? 'Dark' : 'Light';
});
});
});
}
};
</script>
<style>
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;
margin: 0;
padding: 16px;
}
.fp-demo {
max-width: 1100px;
}
.fp-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
}
.fp-demo h2 {
font-size: 17px;
font-weight: 600;
margin: 0 0 10px;
}
.fp-demo h3 {
font-size: 11px;
font-weight: 600;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--smart-text-tertiary-color);
margin: 26px 0 10px;
}
.fp-note {
font-size: 12px;
line-height: 1.6;
color: var(--smart-text-secondary-color);
margin: 0 0 14px;
max-width: 100ch;
}
.fp-note code {
font-family: Consolas, Monaco, monospace;
font-size: 11.5px;
color: var(--smart-industrial-accent-text);
}
/* Faceplates sit side by side on a real screen and wrap rather than shrink - a
faceplate narrower than its columns stops being readable at all. */
.fp-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 14px;
align-items: stretch;
}
.fp-controls {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 14px;
}
.fp-hint {
font-size: 11.5px;
color: var(--smart-text-tertiary-color);
}
.fp-button {
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;
}
.fp-button:hover {
border-color: var(--smart-border-hover);
color: var(--smart-text-color);
}
.fp-button:focus-visible {
outline: 2px solid var(--smart-industrial-focus);
outline-offset: 2px;
box-shadow: 0 0 0 4px var(--smart-industrial-focus-halo);
}
#log {
font-family: Consolas, Monaco, monospace;
font-size: 11.5px;
background: var(--smart-industrial-panel-background, var(--smart-surface));
border: 1px solid var(--smart-industrial-panel-border, var(--smart-border));
border-radius: 3px;
padding: 8px 10px;
}
</style>
What the demo shows
- Control loop faceplate
- The loops of a unit
- Requests and confirmation
- Quality
- Compact mode