Component demo · Model outputs
Anomaly Heatmap
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>AnomalyHeatmap Overview Demo</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="smart-demo-container">
<section id="overview">
<h2>Smart.AnomalyHeatmap displays anomaly scores with a row per tag and a column per time interval, showing the highest score in each cell.</h2>
<p>
A list of anomalies shows which alarms fired. A heatmap shows their pattern: an episode is a run along a row, a common cause is a column, and a drifting sensor is a row that darkens over time. The scores in this demo come from a simple detector in the page; in an application they come from the anomaly detection model, and the component displays whatever scores it is given.
</p>
</section>
<section>
<h2>A process unit over six hours</h2>
<smart-anomaly-heatmap id="unit" label="Unit 200" time-span="21600000" bucket="900000"></smart-anomaly-heatmap>
<div class="demo-actions">
<button type="button" id="tick">Push the next quarter hour</button>
<button type="button" id="values">Show the numbers</button>
<button type="button" id="tighten">Tighten the thresholds</button>
</div>
<p class="demo-log" id="log">Use Tab to focus a cell, the arrow keys to move, and Enter to open the cell.</p>
<p class="demo-hot" id="hot"></p>
</section>
<section>
<h2>Thresholds and colours</h2>
<p>
Below the advisory threshold a cell is drawn in a neutral colour whose intensity follows the score, so low scores are visible without looking like an alarm. Above a threshold the cell takes the colour of its band, so a warning looks the same whether the score is 0.71 or 0.89. The <code>thresholds</code> property sets where each band begins.
</p>
<smart-anomaly-heatmap id="bands" time-span="3600000" bucket="300000" show-values show-time-axis="false"></smart-anomaly-heatmap>
</section>
</div>
<!-- scripts -->
<script type="module" src="node_modules/smart-industrial/source/modules/smart.anomalyheatmap.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" />
/* Anomaly heatmap overview.
*
* The scores are made here by a stand-in: each tag has a slow rhythm of mild scores, one
* runs an episode, one drifts. That is exactly the shape a real detector produces and
* exactly the shape a list hides. Replace `scoreFor` with your model's output and nothing
* else changes. */
window.onload = function () {
const unit = document.getElementById('unit'),
bands = document.getElementById('bands'),
log = document.getElementById('log'),
hot = document.getElementById('hot'),
BUCKET = 900000,
now = Date.now(),
tags = [
{ id: 'TIC-201', label: 'Reactor temperature' },
{ id: 'PIC-202', label: 'Header pressure' },
{ id: 'FIC-203', label: 'Feed flow' },
{ id: 'LIC-204', label: 'Buffer tank level' },
{ id: 'AIC-205', label: 'Product pH' },
{ id: 'M-2301', label: 'Agitator vibration' },
{ id: 'M-2302', label: 'Pump P-204 vibration' },
{ id: 'TIC-206', label: 'Jacket outlet' }
];
let bucket = 0;
/* A stand-in for a model: mild rhythm everywhere, an episode on the pump, a drift on
the pH loop. Deterministic, so the page reads the same on every load. */
function scoreFor(tag, index, count) {
const base = 0.08 + 0.28 * Math.abs(Math.sin(index / 5 + tag.charCodeAt(tag.length - 1)));
if (tag === 'M-2302' && index >= count - 9 && index < count - 3) {
return 0.72 + 0.26 * Math.sin((index - (count - 9)) / 2);
}
if (tag === 'AIC-205') {
return Math.min(0.97, base + (index / count) * 0.75);
}
if (tag === 'TIC-201' && index === count - 12) {
return 0.61;
}
return base;
}
function samples(count) {
const out = [];
tags.forEach(function (tag) {
for (let i = 0; i < count; i++) {
out.push({ series: tag.id, timestamp: now - (count - 1 - i) * BUCKET, score: scoreFor(tag.id, i, count) });
}
});
return out;
}
unit.series = tags;
unit.data = samples(24);
function report() {
const spots = unit.hotspots('warning');
hot.textContent = spots.length ?
spots.length + ' cells at warning or above; worst ' + spots[0].series + ' at ' +
new Date(spots[0].timestamp).toLocaleTimeString('en-GB', { hour12: false, hour: '2-digit', minute: '2-digit' }) +
' (' + spots[0].score.toFixed(2) + ')' :
'Nothing at warning or above.';
}
report();
unit.addEventListener('selectionChange', function (event) {
const cell = event.detail;
log.textContent = cell.series + ' at ' + new Date(cell.timestamp).toLocaleTimeString('en-GB', { hour12: false, hour: '2-digit', minute: '2-digit' }) +
': ' + (isNaN(cell.score) ? 'no score' : 'score ' + cell.score.toFixed(2) + ', ' + cell.severity) + '. Enter opens it.';
});
unit.addEventListener('cellClick', function (event) {
/* Where a screen opens the trend behind the cell, with the model's markers on it. */
log.textContent = 'cellClick: ' + event.detail.series + ' - a screen would open the trend here.';
});
document.getElementById('tick').addEventListener('click', function () {
bucket++;
tags.forEach(function (tag) {
unit.push({ series: tag.id, timestamp: now + bucket * BUCKET, score: scoreFor(tag.id, 24 + bucket, 24 + bucket) });
});
report();
log.textContent = 'Pushed a quarter hour of scores; the axis followed the newest sample.';
});
document.getElementById('values').addEventListener('click', function () {
unit.showValues = !unit.showValues;
this.textContent = unit.showValues ? 'Hide the numbers' : 'Show the numbers';
});
document.getElementById('tighten').addEventListener('click', function () {
const tight = unit.thresholds.advisory === 0.5;
unit.thresholds = tight ? { advisory: 0.35, warning: 0.55, critical: 0.8 } : { advisory: 0.5, warning: 0.7, critical: 0.9 };
this.textContent = tight ? 'Relax the thresholds' : 'Tighten the thresholds';
report();
});
/* The bands, one series, twelve scores from 0.05 to 0.95. */
bands.series = [{ id: 'S', label: 'Score' }];
bands.data = Array.from({ length: 12 }, function (unused, i) {
return { series: 'S', timestamp: now - (11 - i) * 300000, score: 0.05 + i * 0.082 };
});
};
styles.css
.smart-demo-container {
max-width: 900px;
}
section {
margin-bottom: 34px;
}
section h2 {
font-size: 16px;
font-weight: 500;
margin: 0 0 8px;
}
section p {
color: var(--smart-text-secondary-color);
margin: 0 0 14px;
max-width: 74ch;
}
code {
background: var(--smart-ui-state-hover);
font-family: Consolas, "Courier New", monospace;
font-size: 12px;
padding: 1px 5px;
}
.demo-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 14px;
}
.demo-actions 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;
letter-spacing: .06em;
padding: 7px 14px;
text-transform: uppercase;
}
.demo-actions button:hover {
background: var(--smart-ui-state-hover);
border-color: var(--smart-ui-state-border-hover);
color: var(--smart-ui-state-color-hover);
}
.demo-log,
.demo-counts {
font-family: Consolas, "Courier New", monospace;
font-size: 12px;
margin: 12px 0 0;
color: var(--smart-text-tertiary-color);
}
smart-anomaly-heatmap {
width: 100%;
margin-top: 6px;
}
#bands {
--smart-heatmap-cell-size: 48px;
--smart-heatmap-row-height: 30px;
}
.demo-hot {
color: var(--smart-text-secondary-color);
font-size: 12px;
}
/* ── Demo chrome, shared by the Industrial demos ─────────────────────────
Headings, prose, buttons and notes are the same on every demo page. This block
is appended by scripts and comes last, so it wins over the rules above. */
.smart-demo-container {
padding: 4px 0 32px;
}
.smart-demo-container section {
margin-bottom: 36px;
}
.smart-demo-container section h2 {
margin: 0 0 8px;
font-size: 17px;
font-weight: 600;
line-height: 1.35;
color: var(--smart-text-color);
}
.smart-demo-container #overview h2 {
margin-bottom: 10px;
font-size: 20px;
max-width: 78ch;
}
.smart-demo-container section > p {
margin: 0 0 14px;
max-width: 84ch;
font-size: 14px;
line-height: 1.55;
color: var(--smart-text-secondary-color);
}
.smart-demo-container code {
padding: 1px 5px;
border-radius: 2px;
background: var(--smart-ui-state-hover);
font-family: Consolas, "Courier New", monospace;
font-size: 12px;
}
.smart-demo-container .demo-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 10px;
margin-top: 12px;
}
.smart-demo-container .demo-actions label {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--smart-text-color);
}
.smart-demo-container .demo-actions button,
.smart-demo-container .demo-button {
min-height: 30px;
padding: 0 12px;
border: 1px solid var(--smart-border);
border-radius: 3px;
background: var(--smart-industrial-panel-background, var(--smart-surface));
color: var(--smart-text-color);
font: inherit;
font-size: 13px;
font-weight: 500;
letter-spacing: 0;
text-transform: none;
cursor: pointer;
}
.smart-demo-container .demo-actions button:hover,
.smart-demo-container .demo-button:hover {
border-color: var(--smart-ui-state-border-hover);
background: var(--smart-ui-state-hover);
color: var(--smart-ui-state-color-hover);
}
.smart-demo-container .demo-actions button.demo-button-primary,
.smart-demo-container .demo-button-primary {
border-color: var(--smart-primary);
background: var(--smart-primary);
color: var(--smart-primary-color, #fff);
}
.smart-demo-container .demo-actions button[disabled],
.smart-demo-container .demo-button[disabled] {
opacity: 0.5;
cursor: default;
}
.smart-demo-container .demo-actions select,
.smart-demo-container .demo-actions input[type="text"],
.smart-demo-container .demo-actions input[type="number"] {
min-height: 30px;
padding: 2px 6px;
border: 1px solid var(--smart-border);
border-radius: 3px;
background: var(--smart-background);
color: var(--smart-text-color);
font: inherit;
font-size: 13px;
}
.smart-demo-container .hint,
.smart-demo-container .demo-log,
.smart-demo-container .demo-counts {
margin: 10px 0 0;
font-size: 12px;
line-height: 1.5;
color: var(--smart-text-secondary-color);
opacity: 1;
}
.smart-demo-container .demo-log,
.smart-demo-container .demo-counts {
font-family: Consolas, "Courier New", monospace;
}
.smart-demo-container .log {
list-style: none;
margin: 10px 0 0;
padding: 0;
font-size: 12px;
font-variant-numeric: tabular-nums;
color: var(--smart-text-secondary-color);
opacity: 1;
}
.smart-demo-container .log li {
padding: 2px 0;
}
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="smart-demo-container">
<section id="overview">
<h2>Smart.AnomalyHeatmap displays anomaly scores with a row per tag and a column per time interval, showing the highest score in each cell.</h2>
<p>
A list of anomalies shows which alarms fired. A heatmap shows their pattern: an episode is a run along a row, a common cause is a column, and a drifting sensor is a row that darkens over time. The scores in this demo come from a simple detector in the page; in an application they come from the anomaly detection model, and the component displays whatever scores it is given.
</p>
</section>
<section>
<h2>A process unit over six hours</h2>
<smart-anomaly-heatmap id="unit" label="Unit 200" [timeSpan]="21600000" [bucket]="900000"></smart-anomaly-heatmap>
<div class="demo-actions">
<button type="button" id="tick">Push the next quarter hour</button>
<button type="button" id="values">Show the numbers</button>
<button type="button" id="tighten">Tighten the thresholds</button>
</div>
<p class="demo-log" id="log">Use Tab to focus a cell, the arrow keys to move, and Enter to open the cell.</p>
<p class="demo-hot" id="hot"></p>
</section>
<section>
<h2>Thresholds and colours</h2>
<p>
Below the advisory threshold a cell is drawn in a neutral colour whose intensity follows the score, so low scores are visible without looking like an alarm. Above a threshold the cell takes the colour of its band, so a warning looks the same whether the score is 0.71 or 0.89. The <code>thresholds</code> property sets where each band begins.
</p>
<smart-anomaly-heatmap id="bands" [timeSpan]="3600000" [bucket]="300000" [showValues]="true" [showTimeAxis]="false"></smart-anomaly-heatmap>
</section>
</div>
</div>
angular/app.component.ts
import { Component, AfterViewInit, OnDestroy } from '@angular/core';
import { AnomalyHeatmapModule } from 'smart-industrial/angular/anomalyheatmap';
import { runDemo } from './demo';
/**
* Smart.UI Industrial - AnomalyHeatmap 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: [AnomalyHeatmapModule],
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. */
/* Anomaly heatmap overview.
*
* The scores are made here by a stand-in: each tag has a slow rhythm of mild scores, one
* runs an episode, one drifts. That is exactly the shape a real detector produces and
* exactly the shape a list hides. Replace `scoreFor` with your model's output and nothing
* else changes. */
export function runDemo(): void {
const unit = document.getElementById('unit'),
bands = document.getElementById('bands'),
log = document.getElementById('log'),
hot = document.getElementById('hot'),
BUCKET = 900000,
now = Date.now(),
tags = [
{ id: 'TIC-201', label: 'Reactor temperature' },
{ id: 'PIC-202', label: 'Header pressure' },
{ id: 'FIC-203', label: 'Feed flow' },
{ id: 'LIC-204', label: 'Buffer tank level' },
{ id: 'AIC-205', label: 'Product pH' },
{ id: 'M-2301', label: 'Agitator vibration' },
{ id: 'M-2302', label: 'Pump P-204 vibration' },
{ id: 'TIC-206', label: 'Jacket outlet' }
];
let bucket = 0;
/* A stand-in for a model: mild rhythm everywhere, an episode on the pump, a drift on
the pH loop. Deterministic, so the page reads the same on every load. */
function scoreFor(tag, index, count) {
const base = 0.08 + 0.28 * Math.abs(Math.sin(index / 5 + tag.charCodeAt(tag.length - 1)));
if (tag === 'M-2302' && index >= count - 9 && index < count - 3) {
return 0.72 + 0.26 * Math.sin((index - (count - 9)) / 2);
}
if (tag === 'AIC-205') {
return Math.min(0.97, base + (index / count) * 0.75);
}
if (tag === 'TIC-201' && index === count - 12) {
return 0.61;
}
return base;
}
function samples(count) {
const out = [];
tags.forEach(function (tag) {
for (let i = 0; i < count; i++) {
out.push({ series: tag.id, timestamp: now - (count - 1 - i) * BUCKET, score: scoreFor(tag.id, i, count) });
}
});
return out;
}
unit.series = tags;
unit.data = samples(24);
function report() {
const spots = unit.hotspots('warning');
hot.textContent = spots.length ?
spots.length + ' cells at warning or above; worst ' + spots[0].series + ' at ' +
new Date(spots[0].timestamp).toLocaleTimeString('en-GB', { hour12: false, hour: '2-digit', minute: '2-digit' }) +
' (' + spots[0].score.toFixed(2) + ')' :
'Nothing at warning or above.';
}
report();
unit.addEventListener('selectionChange', function (event) {
const cell = event.detail;
log.textContent = cell.series + ' at ' + new Date(cell.timestamp).toLocaleTimeString('en-GB', { hour12: false, hour: '2-digit', minute: '2-digit' }) +
': ' + (isNaN(cell.score) ? 'no score' : 'score ' + cell.score.toFixed(2) + ', ' + cell.severity) + '. Enter opens it.';
});
unit.addEventListener('cellClick', function (event) {
/* Where a screen opens the trend behind the cell, with the model's markers on it. */
log.textContent = 'cellClick: ' + event.detail.series + ' - a screen would open the trend here.';
});
document.getElementById('tick').addEventListener('click', function () {
bucket++;
tags.forEach(function (tag) {
unit.push({ series: tag.id, timestamp: now + bucket * BUCKET, score: scoreFor(tag.id, 24 + bucket, 24 + bucket) });
});
report();
log.textContent = 'Pushed a quarter hour of scores; the axis followed the newest sample.';
});
document.getElementById('values').addEventListener('click', function () {
unit.showValues = !unit.showValues;
this.textContent = unit.showValues ? 'Hide the numbers' : 'Show the numbers';
});
document.getElementById('tighten').addEventListener('click', function () {
const tight = unit.thresholds.advisory === 0.5;
unit.thresholds = tight ? { advisory: 0.35, warning: 0.55, critical: 0.8 } : { advisory: 0.5, warning: 0.7, critical: 0.9 };
this.textContent = tight ? 'Relax the thresholds' : 'Tighten the thresholds';
report();
});
/* The bands, one series, twelve scores from 0.05 to 0.95. */
bands.series = [{ id: 'S', label: 'Score' }];
bands.data = Array.from({ length: 12 }, function (unused, i) {
return { series: 'S', timestamp: now - (11 - i) * 300000, score: 0.05 + i * 0.082 };
});
}
angular/app.component.css
.smart-demo-container {
max-width: 900px;
}
section {
margin-bottom: 34px;
}
section h2 {
font-size: 16px;
font-weight: 500;
margin: 0 0 8px;
}
section p {
color: var(--smart-text-secondary-color);
margin: 0 0 14px;
max-width: 74ch;
}
code {
background: var(--smart-ui-state-hover);
font-family: Consolas, "Courier New", monospace;
font-size: 12px;
padding: 1px 5px;
}
.demo-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 14px;
}
.demo-actions 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;
letter-spacing: .06em;
padding: 7px 14px;
text-transform: uppercase;
}
.demo-actions button:hover {
background: var(--smart-ui-state-hover);
border-color: var(--smart-ui-state-border-hover);
color: var(--smart-ui-state-color-hover);
}
.demo-log,
.demo-counts {
font-family: Consolas, "Courier New", monospace;
font-size: 12px;
margin: 12px 0 0;
color: var(--smart-text-tertiary-color);
}
smart-anomaly-heatmap {
width: 100%;
margin-top: 6px;
}
#bands {
--smart-heatmap-cell-size: 48px;
--smart-heatmap-row-height: 30px;
}
.demo-hot {
color: var(--smart-text-secondary-color);
font-size: 12px;
}
/* ── Demo chrome, shared by the Industrial demos ─────────────────────────
Headings, prose, buttons and notes are the same on every demo page. This block
is appended by scripts and comes last, so it wins over the rules above. */
.smart-demo-container {
padding: 4px 0 32px;
}
.smart-demo-container section {
margin-bottom: 36px;
}
.smart-demo-container section h2 {
margin: 0 0 8px;
font-size: 17px;
font-weight: 600;
line-height: 1.35;
color: var(--smart-text-color);
}
.smart-demo-container #overview h2 {
margin-bottom: 10px;
font-size: 20px;
max-width: 78ch;
}
.smart-demo-container section > p {
margin: 0 0 14px;
max-width: 84ch;
font-size: 14px;
line-height: 1.55;
color: var(--smart-text-secondary-color);
}
.smart-demo-container code {
padding: 1px 5px;
border-radius: 2px;
background: var(--smart-ui-state-hover);
font-family: Consolas, "Courier New", monospace;
font-size: 12px;
}
.smart-demo-container .demo-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 10px;
margin-top: 12px;
}
.smart-demo-container .demo-actions label {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--smart-text-color);
}
.smart-demo-container .demo-actions button,
.smart-demo-container .demo-button {
min-height: 30px;
padding: 0 12px;
border: 1px solid var(--smart-border);
border-radius: 3px;
background: var(--smart-industrial-panel-background, var(--smart-surface));
color: var(--smart-text-color);
font: inherit;
font-size: 13px;
font-weight: 500;
letter-spacing: 0;
text-transform: none;
cursor: pointer;
}
.smart-demo-container .demo-actions button:hover,
.smart-demo-container .demo-button:hover {
border-color: var(--smart-ui-state-border-hover);
background: var(--smart-ui-state-hover);
color: var(--smart-ui-state-color-hover);
}
.smart-demo-container .demo-actions button.demo-button-primary,
.smart-demo-container .demo-button-primary {
border-color: var(--smart-primary);
background: var(--smart-primary);
color: var(--smart-primary-color, #fff);
}
.smart-demo-container .demo-actions button[disabled],
.smart-demo-container .demo-button[disabled] {
opacity: 0.5;
cursor: default;
}
.smart-demo-container .demo-actions select,
.smart-demo-container .demo-actions input[type="text"],
.smart-demo-container .demo-actions input[type="number"] {
min-height: 30px;
padding: 2px 6px;
border: 1px solid var(--smart-border);
border-radius: 3px;
background: var(--smart-background);
color: var(--smart-text-color);
font: inherit;
font-size: 13px;
}
.smart-demo-container .hint,
.smart-demo-container .demo-log,
.smart-demo-container .demo-counts {
margin: 10px 0 0;
font-size: 12px;
line-height: 1.5;
color: var(--smart-text-secondary-color);
opacity: 1;
}
.smart-demo-container .demo-log,
.smart-demo-container .demo-counts {
font-family: Consolas, "Courier New", monospace;
}
.smart-demo-container .log {
list-style: none;
margin: 10px 0 0;
padding: 0;
font-size: 12px;
font-variant-numeric: tabular-nums;
color: var(--smart-text-secondary-color);
opacity: 1;
}
.smart-demo-container .log li {
padding: 2px 0;
}
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 { AnomalyHeatmap } from 'smart-industrial/react/anomalyheatmap';
/* The demo logic is the HTML demo's: it feeds the elements the way a plant's data would. */
/* Anomaly heatmap overview.
*
* The scores are made here by a stand-in: each tag has a slow rhythm of mild scores, one
* runs an episode, one drifts. That is exactly the shape a real detector produces and
* exactly the shape a list hides. Replace `scoreFor` with your model's output and nothing
* else changes. */
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 unit = document.getElementById('unit'),
bands = document.getElementById('bands'),
log = document.getElementById('log'),
hot = document.getElementById('hot'),
BUCKET = 900000,
now = Date.now(),
tags = [
{ id: 'TIC-201', label: 'Reactor temperature' },
{ id: 'PIC-202', label: 'Header pressure' },
{ id: 'FIC-203', label: 'Feed flow' },
{ id: 'LIC-204', label: 'Buffer tank level' },
{ id: 'AIC-205', label: 'Product pH' },
{ id: 'M-2301', label: 'Agitator vibration' },
{ id: 'M-2302', label: 'Pump P-204 vibration' },
{ id: 'TIC-206', label: 'Jacket outlet' }
];
let bucket = 0;
/* A stand-in for a model: mild rhythm everywhere, an episode on the pump, a drift on
the pH loop. Deterministic, so the page reads the same on every load. */
function scoreFor(tag, index, count) {
const base = 0.08 + 0.28 * Math.abs(Math.sin(index / 5 + tag.charCodeAt(tag.length - 1)));
if (tag === 'M-2302' && index >= count - 9 && index < count - 3) {
return 0.72 + 0.26 * Math.sin((index - (count - 9)) / 2);
}
if (tag === 'AIC-205') {
return Math.min(0.97, base + (index / count) * 0.75);
}
if (tag === 'TIC-201' && index === count - 12) {
return 0.61;
}
return base;
}
function samples(count) {
const out = [];
tags.forEach(function (tag) {
for (let i = 0; i < count; i++) {
out.push({ series: tag.id, timestamp: now - (count - 1 - i) * BUCKET, score: scoreFor(tag.id, i, count) });
}
});
return out;
}
unit.series = tags;
unit.data = samples(24);
function report() {
const spots = unit.hotspots('warning');
hot.textContent = spots.length ?
spots.length + ' cells at warning or above; worst ' + spots[0].series + ' at ' +
new Date(spots[0].timestamp).toLocaleTimeString('en-GB', { hour12: false, hour: '2-digit', minute: '2-digit' }) +
' (' + spots[0].score.toFixed(2) + ')' :
'Nothing at warning or above.';
}
report();
unit.addEventListener('selectionChange', function (event) {
const cell = event.detail;
log.textContent = cell.series + ' at ' + new Date(cell.timestamp).toLocaleTimeString('en-GB', { hour12: false, hour: '2-digit', minute: '2-digit' }) +
': ' + (isNaN(cell.score) ? 'no score' : 'score ' + cell.score.toFixed(2) + ', ' + cell.severity) + '. Enter opens it.';
});
unit.addEventListener('cellClick', function (event) {
/* Where a screen opens the trend behind the cell, with the model's markers on it. */
log.textContent = 'cellClick: ' + event.detail.series + ' - a screen would open the trend here.';
});
document.getElementById('tick').addEventListener('click', function () {
bucket++;
tags.forEach(function (tag) {
unit.push({ series: tag.id, timestamp: now + bucket * BUCKET, score: scoreFor(tag.id, 24 + bucket, 24 + bucket) });
});
report();
log.textContent = 'Pushed a quarter hour of scores; the axis followed the newest sample.';
});
document.getElementById('values').addEventListener('click', function () {
unit.showValues = !unit.showValues;
this.textContent = unit.showValues ? 'Hide the numbers' : 'Show the numbers';
});
document.getElementById('tighten').addEventListener('click', function () {
const tight = unit.thresholds.advisory === 0.5;
unit.thresholds = tight ? { advisory: 0.35, warning: 0.55, critical: 0.8 } : { advisory: 0.5, warning: 0.7, critical: 0.9 };
this.textContent = tight ? 'Relax the thresholds' : 'Tighten the thresholds';
report();
});
/* The bands, one series, twelve scores from 0.05 to 0.95. */
bands.series = [{ id: 'S', label: 'Score' }];
bands.data = Array.from({ length: 12 }, function (unused, i) {
return { series: 'S', timestamp: now - (11 - i) * 300000, score: 0.05 + i * 0.082 };
});
});
return () => {
unmounted = true;
};
}, []);
return (
<div className="react-root" ref={root} theme="industrial">
<div className="smart-demo-container">
<section id="overview">
<h2>Smart.AnomalyHeatmap displays anomaly scores with a row per tag and a column per time interval, showing the highest score in each cell.</h2>
<p>A list of anomalies shows which alarms fired. A heatmap shows their pattern: an episode is a run along a row, a common cause is a column, and a drifting sensor is a row that darkens over time. The scores in this demo come from a simple detector in the page; in an application they come from the anomaly detection model, and the component displays whatever scores it is given.</p>
</section>
<section>
<h2>A process unit over six hours</h2>
<AnomalyHeatmap id="unit" label="Unit 200" timeSpan={21600000} bucket={900000}></AnomalyHeatmap>
<div className="demo-actions">
<button type="button" id="tick">Push the next quarter hour</button>
<button type="button" id="values">Show the numbers</button>
<button type="button" id="tighten">Tighten the thresholds</button>
</div>
<p className="demo-log" id="log">Use Tab to focus a cell, the arrow keys to move, and Enter to open the cell.</p>
<p className="demo-hot" id="hot"></p>
</section>
<section>
<h2>Thresholds and colours</h2>
<p>Below the advisory threshold a cell is drawn in a neutral colour whose intensity follows the score, so low scores are visible without looking like an alarm. Above a threshold the cell takes the colour of its band, so a warning looks the same whether the score is 0.71 or 0.89. The <code>thresholds</code> property sets where each band begins.</p>
<AnomalyHeatmap id="bands" timeSpan={3600000} bucket={300000} showValues showTimeAxis={false}></AnomalyHeatmap>
</section>
</div>
</div>
);
};
export default App;
react/App.css
.smart-demo-container {
max-width: 900px;
}
section {
margin-bottom: 34px;
}
section h2 {
font-size: 16px;
font-weight: 500;
margin: 0 0 8px;
}
section p {
color: var(--smart-text-secondary-color);
margin: 0 0 14px;
max-width: 74ch;
}
code {
background: var(--smart-ui-state-hover);
font-family: Consolas, "Courier New", monospace;
font-size: 12px;
padding: 1px 5px;
}
.demo-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 14px;
}
.demo-actions 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;
letter-spacing: .06em;
padding: 7px 14px;
text-transform: uppercase;
}
.demo-actions button:hover {
background: var(--smart-ui-state-hover);
border-color: var(--smart-ui-state-border-hover);
color: var(--smart-ui-state-color-hover);
}
.demo-log,
.demo-counts {
font-family: Consolas, "Courier New", monospace;
font-size: 12px;
margin: 12px 0 0;
color: var(--smart-text-tertiary-color);
}
smart-anomaly-heatmap {
width: 100%;
margin-top: 6px;
}
#bands {
--smart-heatmap-cell-size: 48px;
--smart-heatmap-row-height: 30px;
}
.demo-hot {
color: var(--smart-text-secondary-color);
font-size: 12px;
}
/* ── Demo chrome, shared by the Industrial demos ─────────────────────────
Headings, prose, buttons and notes are the same on every demo page. This block
is appended by scripts and comes last, so it wins over the rules above. */
.smart-demo-container {
padding: 4px 0 32px;
}
.smart-demo-container section {
margin-bottom: 36px;
}
.smart-demo-container section h2 {
margin: 0 0 8px;
font-size: 17px;
font-weight: 600;
line-height: 1.35;
color: var(--smart-text-color);
}
.smart-demo-container #overview h2 {
margin-bottom: 10px;
font-size: 20px;
max-width: 78ch;
}
.smart-demo-container section > p {
margin: 0 0 14px;
max-width: 84ch;
font-size: 14px;
line-height: 1.55;
color: var(--smart-text-secondary-color);
}
.smart-demo-container code {
padding: 1px 5px;
border-radius: 2px;
background: var(--smart-ui-state-hover);
font-family: Consolas, "Courier New", monospace;
font-size: 12px;
}
.smart-demo-container .demo-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 10px;
margin-top: 12px;
}
.smart-demo-container .demo-actions label {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--smart-text-color);
}
.smart-demo-container .demo-actions button,
.smart-demo-container .demo-button {
min-height: 30px;
padding: 0 12px;
border: 1px solid var(--smart-border);
border-radius: 3px;
background: var(--smart-industrial-panel-background, var(--smart-surface));
color: var(--smart-text-color);
font: inherit;
font-size: 13px;
font-weight: 500;
letter-spacing: 0;
text-transform: none;
cursor: pointer;
}
.smart-demo-container .demo-actions button:hover,
.smart-demo-container .demo-button:hover {
border-color: var(--smart-ui-state-border-hover);
background: var(--smart-ui-state-hover);
color: var(--smart-ui-state-color-hover);
}
.smart-demo-container .demo-actions button.demo-button-primary,
.smart-demo-container .demo-button-primary {
border-color: var(--smart-primary);
background: var(--smart-primary);
color: var(--smart-primary-color, #fff);
}
.smart-demo-container .demo-actions button[disabled],
.smart-demo-container .demo-button[disabled] {
opacity: 0.5;
cursor: default;
}
.smart-demo-container .demo-actions select,
.smart-demo-container .demo-actions input[type="text"],
.smart-demo-container .demo-actions input[type="number"] {
min-height: 30px;
padding: 2px 6px;
border: 1px solid var(--smart-border);
border-radius: 3px;
background: var(--smart-background);
color: var(--smart-text-color);
font: inherit;
font-size: 13px;
}
.smart-demo-container .hint,
.smart-demo-container .demo-log,
.smart-demo-container .demo-counts {
margin: 10px 0 0;
font-size: 12px;
line-height: 1.5;
color: var(--smart-text-secondary-color);
opacity: 1;
}
.smart-demo-container .demo-log,
.smart-demo-container .demo-counts {
font-family: Consolas, "Courier New", monospace;
}
.smart-demo-container .log {
list-style: none;
margin: 10px 0 0;
padding: 0;
font-size: 12px;
font-variant-numeric: tabular-nums;
color: var(--smart-text-secondary-color);
opacity: 1;
}
.smart-demo-container .log li {
padding: 2px 0;
}
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="smart-demo-container">
<section id="overview">
<h2>Smart.AnomalyHeatmap displays anomaly scores with a row per tag and a column per time interval, showing the highest score in each cell.</h2>
<p>
A list of anomalies shows which alarms fired. A heatmap shows their pattern: an episode is a run along a row, a common cause is a column, and a drifting sensor is a row that darkens over time. The scores in this demo come from a simple detector in the page; in an application they come from the anomaly detection model, and the component displays whatever scores it is given.
</p>
</section>
<section>
<h2>A process unit over six hours</h2>
<smart-anomaly-heatmap id="unit" label="Unit 200" time-span="21600000" bucket="900000"></smart-anomaly-heatmap>
<div class="demo-actions">
<button type="button" id="tick">Push the next quarter hour</button>
<button type="button" id="values">Show the numbers</button>
<button type="button" id="tighten">Tighten the thresholds</button>
</div>
<p class="demo-log" id="log">Use Tab to focus a cell, the arrow keys to move, and Enter to open the cell.</p>
<p class="demo-hot" id="hot"></p>
</section>
<section>
<h2>Thresholds and colours</h2>
<p>
Below the advisory threshold a cell is drawn in a neutral colour whose intensity follows the score, so low scores are visible without looking like an alarm. Above a threshold the cell takes the colour of its band, so a warning looks the same whether the score is 0.71 or 0.89. The <code>thresholds</code> property sets where each band begins.
</p>
<smart-anomaly-heatmap id="bands" time-span="3600000" bucket="300000" show-values show-time-axis="false"></smart-anomaly-heatmap>
</section>
</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.anomalyheatmap.js";
/* Anomaly heatmap overview.
*
* The scores are made here by a stand-in: each tag has a slow rhythm of mild scores, one
* runs an episode, one drifts. That is exactly the shape a real detector produces and
* exactly the shape a list hides. Replace `scoreFor` with your model's output and nothing
* else changes. */
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 unit = document.getElementById('unit'),
bands = document.getElementById('bands'),
log = document.getElementById('log'),
hot = document.getElementById('hot'),
BUCKET = 900000,
now = Date.now(),
tags = [
{ id: 'TIC-201', label: 'Reactor temperature' },
{ id: 'PIC-202', label: 'Header pressure' },
{ id: 'FIC-203', label: 'Feed flow' },
{ id: 'LIC-204', label: 'Buffer tank level' },
{ id: 'AIC-205', label: 'Product pH' },
{ id: 'M-2301', label: 'Agitator vibration' },
{ id: 'M-2302', label: 'Pump P-204 vibration' },
{ id: 'TIC-206', label: 'Jacket outlet' }
];
let bucket = 0;
/* A stand-in for a model: mild rhythm everywhere, an episode on the pump, a drift on
the pH loop. Deterministic, so the page reads the same on every load. */
function scoreFor(tag, index, count) {
const base = 0.08 + 0.28 * Math.abs(Math.sin(index / 5 + tag.charCodeAt(tag.length - 1)));
if (tag === 'M-2302' && index >= count - 9 && index < count - 3) {
return 0.72 + 0.26 * Math.sin((index - (count - 9)) / 2);
}
if (tag === 'AIC-205') {
return Math.min(0.97, base + (index / count) * 0.75);
}
if (tag === 'TIC-201' && index === count - 12) {
return 0.61;
}
return base;
}
function samples(count) {
const out = [];
tags.forEach(function (tag) {
for (let i = 0; i < count; i++) {
out.push({ series: tag.id, timestamp: now - (count - 1 - i) * BUCKET, score: scoreFor(tag.id, i, count) });
}
});
return out;
}
unit.series = tags;
unit.data = samples(24);
function report() {
const spots = unit.hotspots('warning');
hot.textContent = spots.length ?
spots.length + ' cells at warning or above; worst ' + spots[0].series + ' at ' +
new Date(spots[0].timestamp).toLocaleTimeString('en-GB', { hour12: false, hour: '2-digit', minute: '2-digit' }) +
' (' + spots[0].score.toFixed(2) + ')' :
'Nothing at warning or above.';
}
report();
unit.addEventListener('selectionChange', function (event) {
const cell = event.detail;
log.textContent = cell.series + ' at ' + new Date(cell.timestamp).toLocaleTimeString('en-GB', { hour12: false, hour: '2-digit', minute: '2-digit' }) +
': ' + (isNaN(cell.score) ? 'no score' : 'score ' + cell.score.toFixed(2) + ', ' + cell.severity) + '. Enter opens it.';
});
unit.addEventListener('cellClick', function (event) {
/* Where a screen opens the trend behind the cell, with the model's markers on it. */
log.textContent = 'cellClick: ' + event.detail.series + ' - a screen would open the trend here.';
});
document.getElementById('tick').addEventListener('click', function () {
bucket++;
tags.forEach(function (tag) {
unit.push({ series: tag.id, timestamp: now + bucket * BUCKET, score: scoreFor(tag.id, 24 + bucket, 24 + bucket) });
});
report();
log.textContent = 'Pushed a quarter hour of scores; the axis followed the newest sample.';
});
document.getElementById('values').addEventListener('click', function () {
unit.showValues = !unit.showValues;
this.textContent = unit.showValues ? 'Hide the numbers' : 'Show the numbers';
});
document.getElementById('tighten').addEventListener('click', function () {
const tight = unit.thresholds.advisory === 0.5;
unit.thresholds = tight ? { advisory: 0.35, warning: 0.55, critical: 0.8 } : { advisory: 0.5, warning: 0.7, critical: 0.9 };
this.textContent = tight ? 'Relax the thresholds' : 'Tighten the thresholds';
report();
});
/* The bands, one series, twelve scores from 0.05 to 0.95. */
bands.series = [{ id: 'S', label: 'Score' }];
bands.data = Array.from({ length: 12 }, function (unused, i) {
return { series: 'S', timestamp: now - (11 - i) * 300000, score: 0.05 + i * 0.082 };
});
});
});
}
};
</script>
<style>
.smart-demo-container {
max-width: 900px;
}
section {
margin-bottom: 34px;
}
section h2 {
font-size: 16px;
font-weight: 500;
margin: 0 0 8px;
}
section p {
color: var(--smart-text-secondary-color);
margin: 0 0 14px;
max-width: 74ch;
}
code {
background: var(--smart-ui-state-hover);
font-family: Consolas, "Courier New", monospace;
font-size: 12px;
padding: 1px 5px;
}
.demo-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 14px;
}
.demo-actions 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;
letter-spacing: .06em;
padding: 7px 14px;
text-transform: uppercase;
}
.demo-actions button:hover {
background: var(--smart-ui-state-hover);
border-color: var(--smart-ui-state-border-hover);
color: var(--smart-ui-state-color-hover);
}
.demo-log,
.demo-counts {
font-family: Consolas, "Courier New", monospace;
font-size: 12px;
margin: 12px 0 0;
color: var(--smart-text-tertiary-color);
}
smart-anomaly-heatmap {
width: 100%;
margin-top: 6px;
}
#bands {
--smart-heatmap-cell-size: 48px;
--smart-heatmap-row-height: 30px;
}
.demo-hot {
color: var(--smart-text-secondary-color);
font-size: 12px;
}
/* ── Demo chrome, shared by the Industrial demos ─────────────────────────
Headings, prose, buttons and notes are the same on every demo page. This block
is appended by scripts and comes last, so it wins over the rules above. */
.smart-demo-container {
padding: 4px 0 32px;
}
.smart-demo-container section {
margin-bottom: 36px;
}
.smart-demo-container section h2 {
margin: 0 0 8px;
font-size: 17px;
font-weight: 600;
line-height: 1.35;
color: var(--smart-text-color);
}
.smart-demo-container #overview h2 {
margin-bottom: 10px;
font-size: 20px;
max-width: 78ch;
}
.smart-demo-container section > p {
margin: 0 0 14px;
max-width: 84ch;
font-size: 14px;
line-height: 1.55;
color: var(--smart-text-secondary-color);
}
.smart-demo-container code {
padding: 1px 5px;
border-radius: 2px;
background: var(--smart-ui-state-hover);
font-family: Consolas, "Courier New", monospace;
font-size: 12px;
}
.smart-demo-container .demo-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 10px;
margin-top: 12px;
}
.smart-demo-container .demo-actions label {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--smart-text-color);
}
.smart-demo-container .demo-actions button,
.smart-demo-container .demo-button {
min-height: 30px;
padding: 0 12px;
border: 1px solid var(--smart-border);
border-radius: 3px;
background: var(--smart-industrial-panel-background, var(--smart-surface));
color: var(--smart-text-color);
font: inherit;
font-size: 13px;
font-weight: 500;
letter-spacing: 0;
text-transform: none;
cursor: pointer;
}
.smart-demo-container .demo-actions button:hover,
.smart-demo-container .demo-button:hover {
border-color: var(--smart-ui-state-border-hover);
background: var(--smart-ui-state-hover);
color: var(--smart-ui-state-color-hover);
}
.smart-demo-container .demo-actions button.demo-button-primary,
.smart-demo-container .demo-button-primary {
border-color: var(--smart-primary);
background: var(--smart-primary);
color: var(--smart-primary-color, #fff);
}
.smart-demo-container .demo-actions button[disabled],
.smart-demo-container .demo-button[disabled] {
opacity: 0.5;
cursor: default;
}
.smart-demo-container .demo-actions select,
.smart-demo-container .demo-actions input[type="text"],
.smart-demo-container .demo-actions input[type="number"] {
min-height: 30px;
padding: 2px 6px;
border: 1px solid var(--smart-border);
border-radius: 3px;
background: var(--smart-background);
color: var(--smart-text-color);
font: inherit;
font-size: 13px;
}
.smart-demo-container .hint,
.smart-demo-container .demo-log,
.smart-demo-container .demo-counts {
margin: 10px 0 0;
font-size: 12px;
line-height: 1.5;
color: var(--smart-text-secondary-color);
opacity: 1;
}
.smart-demo-container .demo-log,
.smart-demo-container .demo-counts {
font-family: Consolas, "Courier New", monospace;
}
.smart-demo-container .log {
list-style: none;
margin: 10px 0 0;
padding: 0;
font-size: 12px;
font-variant-numeric: tabular-nums;
color: var(--smart-text-secondary-color);
opacity: 1;
}
.smart-demo-container .log li {
padding: 2px 0;
}
</style>
What the demo shows
- A process unit over six hours
- Thresholds and colours