Component demo · Maintenance and sites
Site Map
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>Site Map 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.SiteMap puts many sites on one picture, so that "where is something wrong" is answered with a place.</h2>
<p>The pump stations of a water network, the turbines of a wind farm, the substations of a grid. The picture is
the plant's own - an image, or an SVG placed inside the element that takes the theme's colours - and sites
are placed on it by percent, so it scales with its box. A site that is fine is neutral grey; colour, the
alarm count and a heavier mark are only for a site that needs someone.</p>
</section>
<section>
<h2>A water network</h2>
<p>Select a site to see it under the map; <b>Open</b> raises <code>siteOpen</code>, for the host to go to that
site's screen. The markers are one tab stop: the arrow keys walk them top to bottom, left to right.</p>
<div class="demo-row">
<smart-site-map id="network" label="Water network" aspect-ratio="16 / 9" selected="ps-north">
<svg class="region" viewBox="0 0 1600 900" aria-hidden="true" focusable="false">
<path class="lake" d="M1090 610 C1160 560 1300 570 1360 630 C1420 690 1380 790 1270 800 C1160 810 1060 760 1060 700 C1060 660 1070 630 1090 610 Z" />
<path class="river" d="M-20 520 C160 500 260 560 380 600 C520 650 640 620 760 560 C880 500 980 560 1090 640" />
<path class="river thin" d="M760 560 C790 420 740 300 800 180 C830 120 880 60 900 -20" />
<path class="town" d="M620 300 L900 280 L960 420 L880 520 L660 540 L580 430 Z" />
<path class="town" d="M1180 180 L1340 170 L1380 300 L1250 340 L1160 290 Z" />
<path class="road" d="M0 250 L480 210 L760 360 L1100 330 L1600 380" />
<path class="road" d="M520 900 L560 640 L760 360 L840 0" />
<path class="road minor" d="M1100 330 L1260 250 L1440 200" />
<path class="road minor" d="M560 640 L900 700 L1380 660" />
<text x="690" y="470">Millbrook</text>
<text x="1200" y="245">Eastfield</text>
<text x="1150" y="720">Harbour</text>
</svg>
</smart-site-map>
</div>
<div class="demo-actions">
<button type="button" id="trip">Trip PS East</button>
<button type="button" id="restore">Put it right</button>
</div>
<p class="hint" id="note">The map raises <code>siteSelect</code> and <code>siteOpen</code>.</p>
</section>
<section>
<h2>On the map of the world</h2>
<p>With <code>tiles</code> the picture is a map, and sites go by latitude and longitude. Drag to pan; zoom
with the buttons, a double-click, a pinch, the plus and minus keys, or Ctrl and the wheel - the wheel alone
still scrolls the page. The tiles are drawn muted, so an alarm is what the eye finds, not a motorway.
<code>detail="popup"</code> opens the card beside its marker; the readings here arrive through
<code>push()</code> every two seconds, as a strip chart's pens do.</p>
<p>These tiles come from OpenStreetMap's public server, which asks for light use; a plant points
<code>tiles</code> at its own tile server, and the map works with no internet at all. Nothing is fetched
until <code>tiles</code> is set.</p>
<div class="demo-row">
<smart-site-map id="world" label="Traun valley water" detail="popup" aspect-ratio="16 / 9"
tiles="https://tile.openstreetmap.org/{z}/{x}/{y}.png"></smart-site-map>
</div>
<div class="demo-actions">
<button type="button" id="tripWorld">Trip PS Wels West</button>
<label><input type="checkbox" id="wheelAlways" /> Zoom with the wheel alone</label>
</div>
<p class="hint" id="worldNote">Drag the map, or select a site.</p>
</section>
<section>
<h2>The same sites as a table</h2>
<p><code>view="list"</code>, or the <b>List</b> button: the sites worst first, for anyone who does not read the
picture - and for a phone.</p>
<div class="demo-row">
<smart-site-map id="list" label="Water network" view="list"></smart-site-map>
</div>
</section>
<section>
<h2>Without a picture</h2>
<p>With no picture the sites sit on a grid. <code>showLabels</code> off names only the sites that need someone:
a wind farm of twenty turbines, two of which do.</p>
<div class="demo-row">
<smart-site-map id="farm" label="North Ridge wind farm" aspect-ratio="2 / 1"></smart-site-map>
</div>
</section>
</div>
<!-- scripts -->
<script type="module" src="node_modules/smart-industrial/source/modules/smart.sitemap.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" />
/* The site map: the page plays the station, which knows each site's state and alarm count. */
window.onload = function () {
const network = document.getElementById('network'),
list = document.getElementById('list'),
farm = document.getElementById('farm'),
note = document.getElementById('note'),
SITES = [
{ id: 'wtp', name: 'Riverside treatment works', x: 16, y: 62, state: 'normal', reading: '412 m³/h' },
{ id: 'res-west', name: 'West reservoir', x: 7, y: 30, state: 'normal', reading: 'level 72 %' },
{ id: 'ps-north', name: 'PS North', x: 30, y: 17, state: 'alarm', alarms: 2, reading: '0.8 bar', description: 'Pump 2 tripped on overcurrent; pump 1 running alone.' },
{ id: 'ps-mill', name: 'PS Mill Lane', x: 45, y: 42, state: 'normal', reading: '3.9 bar' },
{ id: 'res-hill', name: 'Hill reservoir', x: 63, y: 12, state: 'warning', alarms: 1, reading: 'level 38 %', description: 'Level below the refill band.' },
{ id: 'bs-square', name: 'Booster Station Square', x: 66, y: 49, state: 'normal', reading: '3.2 bar' },
{ id: 'ps-east', name: 'PS East', x: 82, y: 33, state: 'normal', reading: '4.1 bar' },
{ id: 'bs-oak', name: 'Booster Oak Street', x: 54, y: 68, state: 'normal', reading: '2.7 bar' },
{ id: 'ps-south', name: 'PS South', x: 36, y: 84, state: 'maintenance', reading: '', description: 'Planned: valve replacement until 16:00.' },
{ id: 'ps-harbour', name: 'PS Harbour', x: 86, y: 76, state: 'offline', reading: 'no data for 14 min' }
],
TURBINES = [];
for (let row = 0; row < 4; row++) {
for (let column = 0; column < 5; column++) {
const n = row * 5 + column + 1;
TURBINES.push({
id: 'wtg-' + n,
name: 'WTG ' + String(n).padStart(2, '0'),
x: 10 + column * 19 + (row % 2) * 6,
y: 16 + row * 22,
state: n === 7 ? 'alarm' : n === 14 ? 'warning' : n === 18 ? 'offline' : 'normal',
alarms: n === 7 ? 1 : n === 14 ? 1 : 0,
reading: n === 18 ? '' : (1.2 + ((n * 37) % 20) / 10).toFixed(1) + ' MW'
});
}
}
const show = () => {
network.sites = SITES.map((site) => Object.assign({}, site));
list.sites = SITES.map((site) => Object.assign({}, site));
};
show();
farm.showLabels = false;
farm.sites = TURBINES;
network.addEventListener('siteSelect', function (event) {
note.textContent = 'Selected ' + SITES.find((site) => site.id === event.detail.id).name + '.';
});
network.addEventListener('siteOpen', function (event) {
note.textContent = 'Open the screen of ' + SITES.find((site) => site.id === event.detail.id).name + '.';
});
document.getElementById('trip').addEventListener('click', function () {
Object.assign(SITES.find((site) => site.id === 'ps-east'), { state: 'alarm', alarms: 3, reading: '0.4 bar', description: 'Both pumps tripped: supply to Eastfield on the reservoir.' });
show();
note.textContent = 'PS East is in alarm.';
});
/* ── On the map of the world: a water utility round Linz, its readings pushed as they change ── */
const world = document.getElementById('world'),
worldNote = document.getElementById('worldNote'),
WORLD = [
{ id: 'wtp-linz', name: 'Linz treatment works', lat: 48.2885, lon: 14.3413, state: 'normal', reading: 612, unit: 'm³/h', description: 'Two filter lines of three in service.', href: 'https://en.wikipedia.org/wiki/Water_treatment' },
{ id: 'ps-wels', name: 'PS Wels West', lat: 48.1612, lon: 13.9921, state: 'normal', reading: 3.9, unit: 'bar' },
{ id: 'ps-traun', name: 'PS Traun', lat: 48.2203, lon: 14.2392, state: 'alarm', alarms: 2, reading: 0.9, unit: 'bar', description: 'Pump 2 tripped on overcurrent.' },
{ id: 'res-poest', name: 'Pöstlingberg reservoir', lat: 48.3240, lon: 14.2585, state: 'warning', alarms: 1, reading: 36, unit: '%', description: 'Level below the refill band.' },
{ id: 'ps-enns', name: 'PS Enns', lat: 48.2147, lon: 14.4750, state: 'normal', reading: 4.2, unit: 'bar' },
{ id: 'ps-steyr', name: 'PS Steyr', lat: 48.0427, lon: 14.4213, state: 'maintenance', reading: 'valve work', description: 'Planned until 16:00.' },
{ id: 'bs-leonding', name: 'Booster Leonding', lat: 48.2605, lon: 14.2426, state: 'normal', reading: 2.8, unit: 'bar' },
{ id: 'ps-eferding', name: 'PS Eferding', lat: 48.3084, lon: 14.0224, state: 'offline', reading: 'no data' }
];
world.sites = WORLD;
world.selected = 'ps-traun';
/* the readings as the outstations send them: only what changed, by site and field */
setInterval(function () {
const record = {};
WORLD.forEach(function (site) {
if (typeof site.reading === 'number' && site.state !== 'offline') {
site.reading = Math.round((site.reading * (1 + (Math.random() - 0.5) * 0.02)) * 100) / 100;
record[site.id + '.reading'] = site.reading;
}
});
world.push(record);
}, 2000);
document.getElementById('tripWorld').addEventListener('click', function (event) {
const tripped = event.target.textContent.indexOf('Trip') === 0;
world.push({ 'ps-wels.state': tripped ? 'alarm' : 'normal', 'ps-wels.alarms': tripped ? 1 : 0 });
event.target.textContent = tripped ? 'Reset PS Wels West' : 'Trip PS Wels West';
worldNote.textContent = tripped ? 'PS Wels West is in alarm: pushed as { "ps-wels.state": "alarm" }.' : 'PS Wels West is back to normal.';
});
document.getElementById('wheelAlways').addEventListener('change', function (event) {
world.wheelZoom = event.target.checked ? 'always' : 'ctrl';
});
world.addEventListener('mapMove', function (event) {
worldNote.textContent = 'Zoom ' + event.detail.zoom + ', centred on ' + event.detail.center.lat.toFixed(4) + ', ' + event.detail.center.lon.toFixed(4) + '.';
});
world.addEventListener('siteOpen', function (event) {
worldNote.textContent = 'Open the screen of ' + WORLD.find((site) => site.id === event.detail.id).name + '.';
});
document.getElementById('restore').addEventListener('click', function () {
Object.assign(SITES.find((site) => site.id === 'ps-east'), { state: 'normal', alarms: 0, reading: '4.1 bar', description: '' });
show();
note.textContent = 'PS East is back to normal.';
});
};
styles.css
/* The map spans a supervisor's screen. */
.demo-row {
display: flex;
flex-wrap: wrap;
gap: 24px;
align-items: flex-start;
}
smart-site-map {
width: 900px;
max-width: 100%;
}
/* The picture takes the theme's colours: muted, so the sites are what stands out. */
.region .lake,
.region .river {
fill: none;
stroke: color-mix(in srgb, var(--smart-primary) 30%, var(--smart-surface));
stroke-width: 26;
stroke-linecap: round;
}
.region .lake {
fill: color-mix(in srgb, var(--smart-primary) 30%, var(--smart-surface));
stroke: none;
}
.region .river.thin {
stroke-width: 12;
}
.region .town {
fill: color-mix(in srgb, var(--smart-text-color) 7%, var(--smart-surface));
}
.region .road {
fill: none;
stroke: var(--smart-border);
stroke-width: 8;
stroke-linecap: round;
stroke-linejoin: round;
}
.region .road.minor {
stroke-width: 4;
}
.region text {
fill: var(--smart-text-tertiary-color);
font-size: 28px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
/* ── 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.SiteMap puts many sites on one picture, so that "where is something wrong" is answered with a place.</h2>
<p>The pump stations of a water network, the turbines of a wind farm, the substations of a grid. The picture is
the plant's own - an image, or an SVG placed inside the element that takes the theme's colours - and sites
are placed on it by percent, so it scales with its box. A site that is fine is neutral grey; colour, the
alarm count and a heavier mark are only for a site that needs someone.</p>
</section>
<section>
<h2>A water network</h2>
<p>Select a site to see it under the map; <b>Open</b> raises <code>siteOpen</code>, for the host to go to that
site's screen. The markers are one tab stop: the arrow keys walk them top to bottom, left to right.</p>
<div class="demo-row">
<smart-site-map id="network" label="Water network" aspect-ratio="16 / 9" selected="ps-north">
<svg class="region" viewBox="0 0 1600 900" aria-hidden="true" focusable="false">
<path class="lake" d="M1090 610 C1160 560 1300 570 1360 630 C1420 690 1380 790 1270 800 C1160 810 1060 760 1060 700 C1060 660 1070 630 1090 610 Z" />
<path class="river" d="M-20 520 C160 500 260 560 380 600 C520 650 640 620 760 560 C880 500 980 560 1090 640" />
<path class="river thin" d="M760 560 C790 420 740 300 800 180 C830 120 880 60 900 -20" />
<path class="town" d="M620 300 L900 280 L960 420 L880 520 L660 540 L580 430 Z" />
<path class="town" d="M1180 180 L1340 170 L1380 300 L1250 340 L1160 290 Z" />
<path class="road" d="M0 250 L480 210 L760 360 L1100 330 L1600 380" />
<path class="road" d="M520 900 L560 640 L760 360 L840 0" />
<path class="road minor" d="M1100 330 L1260 250 L1440 200" />
<path class="road minor" d="M560 640 L900 700 L1380 660" />
<text x="690" y="470">Millbrook</text>
<text x="1200" y="245">Eastfield</text>
<text x="1150" y="720">Harbour</text>
</svg>
</smart-site-map>
</div>
<div class="demo-actions">
<button type="button" id="trip">Trip PS East</button>
<button type="button" id="restore">Put it right</button>
</div>
<p class="hint" id="note">The map raises <code>siteSelect</code> and <code>siteOpen</code>.</p>
</section>
<section>
<h2>On the map of the world</h2>
<p>With <code>tiles</code> the picture is a map, and sites go by latitude and longitude. Drag to pan; zoom
with the buttons, a double-click, a pinch, the plus and minus keys, or Ctrl and the wheel - the wheel alone
still scrolls the page. The tiles are drawn muted, so an alarm is what the eye finds, not a motorway.
<code>detail="popup"</code> opens the card beside its marker; the readings here arrive through
<code>push()</code> every two seconds, as a strip chart's pens do.</p>
<p>These tiles come from OpenStreetMap's public server, which asks for light use; a plant points
<code>tiles</code> at its own tile server, and the map works with no internet at all. Nothing is fetched
until <code>tiles</code> is set.</p>
<div class="demo-row">
<smart-site-map id="world" label="Traun valley water" detail="popup" aspect-ratio="16 / 9"
tiles="https://tile.openstreetmap.org/{z}/{x}/{y}.png"></smart-site-map>
</div>
<div class="demo-actions">
<button type="button" id="tripWorld">Trip PS Wels West</button>
<label><input type="checkbox" id="wheelAlways" /> Zoom with the wheel alone</label>
</div>
<p class="hint" id="worldNote">Drag the map, or select a site.</p>
</section>
<section>
<h2>The same sites as a table</h2>
<p><code>view="list"</code>, or the <b>List</b> button: the sites worst first, for anyone who does not read the
picture - and for a phone.</p>
<div class="demo-row">
<smart-site-map id="list" label="Water network" view="list"></smart-site-map>
</div>
</section>
<section>
<h2>Without a picture</h2>
<p>With no picture the sites sit on a grid. <code>showLabels</code> off names only the sites that need someone:
a wind farm of twenty turbines, two of which do.</p>
<div class="demo-row">
<smart-site-map id="farm" label="North Ridge wind farm" aspect-ratio="2 / 1"></smart-site-map>
</div>
</section>
</div>
</div>
angular/app.component.ts
import { Component, AfterViewInit, OnDestroy } from '@angular/core';
import { SiteMapModule } from 'smart-industrial/angular/sitemap';
import { runDemo } from './demo';
/**
* Smart.UI Industrial - SiteMap 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: [SiteMapModule],
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. */
/* The site map: the page plays the station, which knows each site's state and alarm count. */
export function runDemo(): void {
const network = document.getElementById('network'),
list = document.getElementById('list'),
farm = document.getElementById('farm'),
note = document.getElementById('note'),
SITES = [
{ id: 'wtp', name: 'Riverside treatment works', x: 16, y: 62, state: 'normal', reading: '412 m³/h' },
{ id: 'res-west', name: 'West reservoir', x: 7, y: 30, state: 'normal', reading: 'level 72 %' },
{ id: 'ps-north', name: 'PS North', x: 30, y: 17, state: 'alarm', alarms: 2, reading: '0.8 bar', description: 'Pump 2 tripped on overcurrent; pump 1 running alone.' },
{ id: 'ps-mill', name: 'PS Mill Lane', x: 45, y: 42, state: 'normal', reading: '3.9 bar' },
{ id: 'res-hill', name: 'Hill reservoir', x: 63, y: 12, state: 'warning', alarms: 1, reading: 'level 38 %', description: 'Level below the refill band.' },
{ id: 'bs-square', name: 'Booster Station Square', x: 66, y: 49, state: 'normal', reading: '3.2 bar' },
{ id: 'ps-east', name: 'PS East', x: 82, y: 33, state: 'normal', reading: '4.1 bar' },
{ id: 'bs-oak', name: 'Booster Oak Street', x: 54, y: 68, state: 'normal', reading: '2.7 bar' },
{ id: 'ps-south', name: 'PS South', x: 36, y: 84, state: 'maintenance', reading: '', description: 'Planned: valve replacement until 16:00.' },
{ id: 'ps-harbour', name: 'PS Harbour', x: 86, y: 76, state: 'offline', reading: 'no data for 14 min' }
],
TURBINES = [];
for (let row = 0; row < 4; row++) {
for (let column = 0; column < 5; column++) {
const n = row * 5 + column + 1;
TURBINES.push({
id: 'wtg-' + n,
name: 'WTG ' + String(n).padStart(2, '0'),
x: 10 + column * 19 + (row % 2) * 6,
y: 16 + row * 22,
state: n === 7 ? 'alarm' : n === 14 ? 'warning' : n === 18 ? 'offline' : 'normal',
alarms: n === 7 ? 1 : n === 14 ? 1 : 0,
reading: n === 18 ? '' : (1.2 + ((n * 37) % 20) / 10).toFixed(1) + ' MW'
});
}
}
const show = () => {
network.sites = SITES.map((site) => Object.assign({}, site));
list.sites = SITES.map((site) => Object.assign({}, site));
};
show();
farm.showLabels = false;
farm.sites = TURBINES;
network.addEventListener('siteSelect', function (event) {
note.textContent = 'Selected ' + SITES.find((site) => site.id === event.detail.id).name + '.';
});
network.addEventListener('siteOpen', function (event) {
note.textContent = 'Open the screen of ' + SITES.find((site) => site.id === event.detail.id).name + '.';
});
document.getElementById('trip').addEventListener('click', function () {
Object.assign(SITES.find((site) => site.id === 'ps-east'), { state: 'alarm', alarms: 3, reading: '0.4 bar', description: 'Both pumps tripped: supply to Eastfield on the reservoir.' });
show();
note.textContent = 'PS East is in alarm.';
});
/* ── On the map of the world: a water utility round Linz, its readings pushed as they change ── */
const world = document.getElementById('world'),
worldNote = document.getElementById('worldNote'),
WORLD = [
{ id: 'wtp-linz', name: 'Linz treatment works', lat: 48.2885, lon: 14.3413, state: 'normal', reading: 612, unit: 'm³/h', description: 'Two filter lines of three in service.', href: 'https://en.wikipedia.org/wiki/Water_treatment' },
{ id: 'ps-wels', name: 'PS Wels West', lat: 48.1612, lon: 13.9921, state: 'normal', reading: 3.9, unit: 'bar' },
{ id: 'ps-traun', name: 'PS Traun', lat: 48.2203, lon: 14.2392, state: 'alarm', alarms: 2, reading: 0.9, unit: 'bar', description: 'Pump 2 tripped on overcurrent.' },
{ id: 'res-poest', name: 'Pöstlingberg reservoir', lat: 48.3240, lon: 14.2585, state: 'warning', alarms: 1, reading: 36, unit: '%', description: 'Level below the refill band.' },
{ id: 'ps-enns', name: 'PS Enns', lat: 48.2147, lon: 14.4750, state: 'normal', reading: 4.2, unit: 'bar' },
{ id: 'ps-steyr', name: 'PS Steyr', lat: 48.0427, lon: 14.4213, state: 'maintenance', reading: 'valve work', description: 'Planned until 16:00.' },
{ id: 'bs-leonding', name: 'Booster Leonding', lat: 48.2605, lon: 14.2426, state: 'normal', reading: 2.8, unit: 'bar' },
{ id: 'ps-eferding', name: 'PS Eferding', lat: 48.3084, lon: 14.0224, state: 'offline', reading: 'no data' }
];
world.sites = WORLD;
world.selected = 'ps-traun';
/* the readings as the outstations send them: only what changed, by site and field */
setInterval(function () {
const record = {};
WORLD.forEach(function (site) {
if (typeof site.reading === 'number' && site.state !== 'offline') {
site.reading = Math.round((site.reading * (1 + (Math.random() - 0.5) * 0.02)) * 100) / 100;
record[site.id + '.reading'] = site.reading;
}
});
world.push(record);
}, 2000);
document.getElementById('tripWorld').addEventListener('click', function (event) {
const tripped = event.target.textContent.indexOf('Trip') === 0;
world.push({ 'ps-wels.state': tripped ? 'alarm' : 'normal', 'ps-wels.alarms': tripped ? 1 : 0 });
event.target.textContent = tripped ? 'Reset PS Wels West' : 'Trip PS Wels West';
worldNote.textContent = tripped ? 'PS Wels West is in alarm: pushed as { "ps-wels.state": "alarm" }.' : 'PS Wels West is back to normal.';
});
document.getElementById('wheelAlways').addEventListener('change', function (event) {
world.wheelZoom = event.target.checked ? 'always' : 'ctrl';
});
world.addEventListener('mapMove', function (event) {
worldNote.textContent = 'Zoom ' + event.detail.zoom + ', centred on ' + event.detail.center.lat.toFixed(4) + ', ' + event.detail.center.lon.toFixed(4) + '.';
});
world.addEventListener('siteOpen', function (event) {
worldNote.textContent = 'Open the screen of ' + WORLD.find((site) => site.id === event.detail.id).name + '.';
});
document.getElementById('restore').addEventListener('click', function () {
Object.assign(SITES.find((site) => site.id === 'ps-east'), { state: 'normal', alarms: 0, reading: '4.1 bar', description: '' });
show();
note.textContent = 'PS East is back to normal.';
});
}
angular/app.component.css
/* The map spans a supervisor's screen. */
.demo-row {
display: flex;
flex-wrap: wrap;
gap: 24px;
align-items: flex-start;
}
smart-site-map {
width: 900px;
max-width: 100%;
}
/* The picture takes the theme's colours: muted, so the sites are what stands out. */
.region .lake,
.region .river {
fill: none;
stroke: color-mix(in srgb, var(--smart-primary) 30%, var(--smart-surface));
stroke-width: 26;
stroke-linecap: round;
}
.region .lake {
fill: color-mix(in srgb, var(--smart-primary) 30%, var(--smart-surface));
stroke: none;
}
.region .river.thin {
stroke-width: 12;
}
.region .town {
fill: color-mix(in srgb, var(--smart-text-color) 7%, var(--smart-surface));
}
.region .road {
fill: none;
stroke: var(--smart-border);
stroke-width: 8;
stroke-linecap: round;
stroke-linejoin: round;
}
.region .road.minor {
stroke-width: 4;
}
.region text {
fill: var(--smart-text-tertiary-color);
font-size: 28px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
/* ── 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 { SiteMap } from 'smart-industrial/react/sitemap';
/* The demo logic is the HTML demo's: it feeds the elements the way a plant's data would. */
/* The site map: the page plays the station, which knows each site's state and alarm count. */
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 network = document.getElementById('network'),
list = document.getElementById('list'),
farm = document.getElementById('farm'),
note = document.getElementById('note'),
SITES = [
{ id: 'wtp', name: 'Riverside treatment works', x: 16, y: 62, state: 'normal', reading: '412 m³/h' },
{ id: 'res-west', name: 'West reservoir', x: 7, y: 30, state: 'normal', reading: 'level 72 %' },
{ id: 'ps-north', name: 'PS North', x: 30, y: 17, state: 'alarm', alarms: 2, reading: '0.8 bar', description: 'Pump 2 tripped on overcurrent; pump 1 running alone.' },
{ id: 'ps-mill', name: 'PS Mill Lane', x: 45, y: 42, state: 'normal', reading: '3.9 bar' },
{ id: 'res-hill', name: 'Hill reservoir', x: 63, y: 12, state: 'warning', alarms: 1, reading: 'level 38 %', description: 'Level below the refill band.' },
{ id: 'bs-square', name: 'Booster Station Square', x: 66, y: 49, state: 'normal', reading: '3.2 bar' },
{ id: 'ps-east', name: 'PS East', x: 82, y: 33, state: 'normal', reading: '4.1 bar' },
{ id: 'bs-oak', name: 'Booster Oak Street', x: 54, y: 68, state: 'normal', reading: '2.7 bar' },
{ id: 'ps-south', name: 'PS South', x: 36, y: 84, state: 'maintenance', reading: '', description: 'Planned: valve replacement until 16:00.' },
{ id: 'ps-harbour', name: 'PS Harbour', x: 86, y: 76, state: 'offline', reading: 'no data for 14 min' }
],
TURBINES = [];
for (let row = 0; row < 4; row++) {
for (let column = 0; column < 5; column++) {
const n = row * 5 + column + 1;
TURBINES.push({
id: 'wtg-' + n,
name: 'WTG ' + String(n).padStart(2, '0'),
x: 10 + column * 19 + (row % 2) * 6,
y: 16 + row * 22,
state: n === 7 ? 'alarm' : n === 14 ? 'warning' : n === 18 ? 'offline' : 'normal',
alarms: n === 7 ? 1 : n === 14 ? 1 : 0,
reading: n === 18 ? '' : (1.2 + ((n * 37) % 20) / 10).toFixed(1) + ' MW'
});
}
}
const show = () => {
network.sites = SITES.map((site) => Object.assign({}, site));
list.sites = SITES.map((site) => Object.assign({}, site));
};
show();
farm.showLabels = false;
farm.sites = TURBINES;
network.addEventListener('siteSelect', function (event) {
note.textContent = 'Selected ' + SITES.find((site) => site.id === event.detail.id).name + '.';
});
network.addEventListener('siteOpen', function (event) {
note.textContent = 'Open the screen of ' + SITES.find((site) => site.id === event.detail.id).name + '.';
});
document.getElementById('trip').addEventListener('click', function () {
Object.assign(SITES.find((site) => site.id === 'ps-east'), { state: 'alarm', alarms: 3, reading: '0.4 bar', description: 'Both pumps tripped: supply to Eastfield on the reservoir.' });
show();
note.textContent = 'PS East is in alarm.';
});
/* ── On the map of the world: a water utility round Linz, its readings pushed as they change ── */
const world = document.getElementById('world'),
worldNote = document.getElementById('worldNote'),
WORLD = [
{ id: 'wtp-linz', name: 'Linz treatment works', lat: 48.2885, lon: 14.3413, state: 'normal', reading: 612, unit: 'm³/h', description: 'Two filter lines of three in service.', href: 'https://en.wikipedia.org/wiki/Water_treatment' },
{ id: 'ps-wels', name: 'PS Wels West', lat: 48.1612, lon: 13.9921, state: 'normal', reading: 3.9, unit: 'bar' },
{ id: 'ps-traun', name: 'PS Traun', lat: 48.2203, lon: 14.2392, state: 'alarm', alarms: 2, reading: 0.9, unit: 'bar', description: 'Pump 2 tripped on overcurrent.' },
{ id: 'res-poest', name: 'Pöstlingberg reservoir', lat: 48.3240, lon: 14.2585, state: 'warning', alarms: 1, reading: 36, unit: '%', description: 'Level below the refill band.' },
{ id: 'ps-enns', name: 'PS Enns', lat: 48.2147, lon: 14.4750, state: 'normal', reading: 4.2, unit: 'bar' },
{ id: 'ps-steyr', name: 'PS Steyr', lat: 48.0427, lon: 14.4213, state: 'maintenance', reading: 'valve work', description: 'Planned until 16:00.' },
{ id: 'bs-leonding', name: 'Booster Leonding', lat: 48.2605, lon: 14.2426, state: 'normal', reading: 2.8, unit: 'bar' },
{ id: 'ps-eferding', name: 'PS Eferding', lat: 48.3084, lon: 14.0224, state: 'offline', reading: 'no data' }
];
world.sites = WORLD;
world.selected = 'ps-traun';
/* the readings as the outstations send them: only what changed, by site and field */
setInterval(function () {
const record = {};
WORLD.forEach(function (site) {
if (typeof site.reading === 'number' && site.state !== 'offline') {
site.reading = Math.round((site.reading * (1 + (Math.random() - 0.5) * 0.02)) * 100) / 100;
record[site.id + '.reading'] = site.reading;
}
});
world.push(record);
}, 2000);
document.getElementById('tripWorld').addEventListener('click', function (event) {
const tripped = event.target.textContent.indexOf('Trip') === 0;
world.push({ 'ps-wels.state': tripped ? 'alarm' : 'normal', 'ps-wels.alarms': tripped ? 1 : 0 });
event.target.textContent = tripped ? 'Reset PS Wels West' : 'Trip PS Wels West';
worldNote.textContent = tripped ? 'PS Wels West is in alarm: pushed as { "ps-wels.state": "alarm" }.' : 'PS Wels West is back to normal.';
});
document.getElementById('wheelAlways').addEventListener('change', function (event) {
world.wheelZoom = event.target.checked ? 'always' : 'ctrl';
});
world.addEventListener('mapMove', function (event) {
worldNote.textContent = 'Zoom ' + event.detail.zoom + ', centred on ' + event.detail.center.lat.toFixed(4) + ', ' + event.detail.center.lon.toFixed(4) + '.';
});
world.addEventListener('siteOpen', function (event) {
worldNote.textContent = 'Open the screen of ' + WORLD.find((site) => site.id === event.detail.id).name + '.';
});
document.getElementById('restore').addEventListener('click', function () {
Object.assign(SITES.find((site) => site.id === 'ps-east'), { state: 'normal', alarms: 0, reading: '4.1 bar', description: '' });
show();
note.textContent = 'PS East is back to normal.';
});
});
return () => {
unmounted = true;
};
}, []);
return (
<div className="react-root" ref={root} theme="industrial">
<div className="smart-demo-container">
<section id="overview">
<h2>Smart.SiteMap puts many sites on one picture, so that "where is something wrong" is answered with a place.</h2>
<p>The pump stations of a water network, the turbines of a wind farm, the substations of a grid. The picture is the plant's own - an image, or an SVG placed inside the element that takes the theme's colours - and sites are placed on it by percent, so it scales with its box. A site that is fine is neutral grey; colour, the alarm count and a heavier mark are only for a site that needs someone.</p>
</section>
<section>
<h2>A water network</h2>
<p>Select a site to see it under the map; <b>Open</b> raises <code>siteOpen</code>, for the host to go to that site's screen. The markers are one tab stop: the arrow keys walk them top to bottom, left to right.</p>
<div className="demo-row">
<SiteMap id="network" label="Water network" aspectRatio="16 / 9" selected="ps-north"><svg className="region" viewBox="0 0 1600 900" aria-hidden="true" focusable="false"><path className="lake" d="M1090 610 C1160 560 1300 570 1360 630 C1420 690 1380 790 1270 800 C1160 810 1060 760 1060 700 C1060 660 1070 630 1090 610 Z"></path> <path className="river" d="M-20 520 C160 500 260 560 380 600 C520 650 640 620 760 560 C880 500 980 560 1090 640"></path> <path className="river thin" d="M760 560 C790 420 740 300 800 180 C830 120 880 60 900 -20"></path> <path className="town" d="M620 300 L900 280 L960 420 L880 520 L660 540 L580 430 Z"></path> <path className="town" d="M1180 180 L1340 170 L1380 300 L1250 340 L1160 290 Z"></path> <path className="road" d="M0 250 L480 210 L760 360 L1100 330 L1600 380"></path> <path className="road" d="M520 900 L560 640 L760 360 L840 0"></path> <path className="road minor" d="M1100 330 L1260 250 L1440 200"></path> <path className="road minor" d="M560 640 L900 700 L1380 660"></path> <text x="690" y="470">Millbrook</text> <text x="1200" y="245">Eastfield</text> <text x="1150" y="720">Harbour</text></svg></SiteMap>
</div>
<div className="demo-actions">
<button type="button" id="trip">Trip PS East</button>
<button type="button" id="restore">Put it right</button>
</div>
<p className="hint" id="note">The map raises <code>siteSelect</code> and <code>siteOpen</code>.</p>
</section>
<section>
<h2>On the map of the world</h2>
<p>With <code>tiles</code> the picture is a map, and sites go by latitude and longitude. Drag to pan; zoom with the buttons, a double-click, a pinch, the plus and minus keys, or Ctrl and the wheel - the wheel alone still scrolls the page. The tiles are drawn muted, so an alarm is what the eye finds, not a motorway. <code>detail="popup"</code> opens the card beside its marker; the readings here arrive through <code>push()</code> every two seconds, as a strip chart's pens do.</p>
<p>These tiles come from OpenStreetMap's public server, which asks for light use; a plant points <code>tiles</code> at its own tile server, and the map works with no internet at all. Nothing is fetched until <code>tiles</code> is set.</p>
<div className="demo-row">
<SiteMap id="world" label="Traun valley water" detail="popup" aspectRatio="16 / 9" tiles="https://tile.openstreetmap.org/{z}/{x}/{y}.png"></SiteMap>
</div>
<div className="demo-actions">
<button type="button" id="tripWorld">Trip PS Wels West</button>
<label><input type="checkbox" id="wheelAlways" /> Zoom with the wheel alone</label>
</div>
<p className="hint" id="worldNote">Drag the map, or select a site.</p>
</section>
<section>
<h2>The same sites as a table</h2>
<p><code>view="list"</code>, or the <b>List</b> button: the sites worst first, for anyone who does not read the picture - and for a phone.</p>
<div className="demo-row">
<SiteMap id="list" label="Water network" view="list"></SiteMap>
</div>
</section>
<section>
<h2>Without a picture</h2>
<p>With no picture the sites sit on a grid. <code>showLabels</code> off names only the sites that need someone: a wind farm of twenty turbines, two of which do.</p>
<div className="demo-row">
<SiteMap id="farm" label="North Ridge wind farm" aspectRatio="2 / 1"></SiteMap>
</div>
</section>
</div>
</div>
);
};
export default App;
react/App.css
/* The map spans a supervisor's screen. */
.demo-row {
display: flex;
flex-wrap: wrap;
gap: 24px;
align-items: flex-start;
}
smart-site-map {
width: 900px;
max-width: 100%;
}
/* The picture takes the theme's colours: muted, so the sites are what stands out. */
.region .lake,
.region .river {
fill: none;
stroke: color-mix(in srgb, var(--smart-primary) 30%, var(--smart-surface));
stroke-width: 26;
stroke-linecap: round;
}
.region .lake {
fill: color-mix(in srgb, var(--smart-primary) 30%, var(--smart-surface));
stroke: none;
}
.region .river.thin {
stroke-width: 12;
}
.region .town {
fill: color-mix(in srgb, var(--smart-text-color) 7%, var(--smart-surface));
}
.region .road {
fill: none;
stroke: var(--smart-border);
stroke-width: 8;
stroke-linecap: round;
stroke-linejoin: round;
}
.region .road.minor {
stroke-width: 4;
}
.region text {
fill: var(--smart-text-tertiary-color);
font-size: 28px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
/* ── 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.SiteMap puts many sites on one picture, so that "where is something wrong" is answered with a place.</h2>
<p>The pump stations of a water network, the turbines of a wind farm, the substations of a grid. The picture is
the plant's own - an image, or an SVG placed inside the element that takes the theme's colours - and sites
are placed on it by percent, so it scales with its box. A site that is fine is neutral grey; colour, the
alarm count and a heavier mark are only for a site that needs someone.</p>
</section>
<section>
<h2>A water network</h2>
<p>Select a site to see it under the map; <b>Open</b> raises <code>siteOpen</code>, for the host to go to that
site's screen. The markers are one tab stop: the arrow keys walk them top to bottom, left to right.</p>
<div class="demo-row">
<smart-site-map id="network" label="Water network" aspect-ratio="16 / 9" selected="ps-north">
<svg class="region" viewBox="0 0 1600 900" aria-hidden="true" focusable="false">
<path class="lake" d="M1090 610 C1160 560 1300 570 1360 630 C1420 690 1380 790 1270 800 C1160 810 1060 760 1060 700 C1060 660 1070 630 1090 610 Z" />
<path class="river" d="M-20 520 C160 500 260 560 380 600 C520 650 640 620 760 560 C880 500 980 560 1090 640" />
<path class="river thin" d="M760 560 C790 420 740 300 800 180 C830 120 880 60 900 -20" />
<path class="town" d="M620 300 L900 280 L960 420 L880 520 L660 540 L580 430 Z" />
<path class="town" d="M1180 180 L1340 170 L1380 300 L1250 340 L1160 290 Z" />
<path class="road" d="M0 250 L480 210 L760 360 L1100 330 L1600 380" />
<path class="road" d="M520 900 L560 640 L760 360 L840 0" />
<path class="road minor" d="M1100 330 L1260 250 L1440 200" />
<path class="road minor" d="M560 640 L900 700 L1380 660" />
<text x="690" y="470">Millbrook</text>
<text x="1200" y="245">Eastfield</text>
<text x="1150" y="720">Harbour</text>
</svg>
</smart-site-map>
</div>
<div class="demo-actions">
<button type="button" id="trip">Trip PS East</button>
<button type="button" id="restore">Put it right</button>
</div>
<p class="hint" id="note">The map raises <code>siteSelect</code> and <code>siteOpen</code>.</p>
</section>
<section>
<h2>On the map of the world</h2>
<p>With <code>tiles</code> the picture is a map, and sites go by latitude and longitude. Drag to pan; zoom
with the buttons, a double-click, a pinch, the plus and minus keys, or Ctrl and the wheel - the wheel alone
still scrolls the page. The tiles are drawn muted, so an alarm is what the eye finds, not a motorway.
<code>detail="popup"</code> opens the card beside its marker; the readings here arrive through
<code>push()</code> every two seconds, as a strip chart's pens do.</p>
<p>These tiles come from OpenStreetMap's public server, which asks for light use; a plant points
<code>tiles</code> at its own tile server, and the map works with no internet at all. Nothing is fetched
until <code>tiles</code> is set.</p>
<div class="demo-row">
<smart-site-map id="world" label="Traun valley water" detail="popup" aspect-ratio="16 / 9"
tiles="https://tile.openstreetmap.org/{z}/{x}/{y}.png"></smart-site-map>
</div>
<div class="demo-actions">
<button type="button" id="tripWorld">Trip PS Wels West</button>
<label><input type="checkbox" id="wheelAlways" /> Zoom with the wheel alone</label>
</div>
<p class="hint" id="worldNote">Drag the map, or select a site.</p>
</section>
<section>
<h2>The same sites as a table</h2>
<p><code>view="list"</code>, or the <b>List</b> button: the sites worst first, for anyone who does not read the
picture - and for a phone.</p>
<div class="demo-row">
<smart-site-map id="list" label="Water network" view="list"></smart-site-map>
</div>
</section>
<section>
<h2>Without a picture</h2>
<p>With no picture the sites sit on a grid. <code>showLabels</code> off names only the sites that need someone:
a wind farm of twenty turbines, two of which do.</p>
<div class="demo-row">
<smart-site-map id="farm" label="North Ridge wind farm" aspect-ratio="2 / 1"></smart-site-map>
</div>
</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.sitemap.js";
/* The site map: the page plays the station, which knows each site's state and alarm count. */
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 network = document.getElementById('network'),
list = document.getElementById('list'),
farm = document.getElementById('farm'),
note = document.getElementById('note'),
SITES = [
{ id: 'wtp', name: 'Riverside treatment works', x: 16, y: 62, state: 'normal', reading: '412 m³/h' },
{ id: 'res-west', name: 'West reservoir', x: 7, y: 30, state: 'normal', reading: 'level 72 %' },
{ id: 'ps-north', name: 'PS North', x: 30, y: 17, state: 'alarm', alarms: 2, reading: '0.8 bar', description: 'Pump 2 tripped on overcurrent; pump 1 running alone.' },
{ id: 'ps-mill', name: 'PS Mill Lane', x: 45, y: 42, state: 'normal', reading: '3.9 bar' },
{ id: 'res-hill', name: 'Hill reservoir', x: 63, y: 12, state: 'warning', alarms: 1, reading: 'level 38 %', description: 'Level below the refill band.' },
{ id: 'bs-square', name: 'Booster Station Square', x: 66, y: 49, state: 'normal', reading: '3.2 bar' },
{ id: 'ps-east', name: 'PS East', x: 82, y: 33, state: 'normal', reading: '4.1 bar' },
{ id: 'bs-oak', name: 'Booster Oak Street', x: 54, y: 68, state: 'normal', reading: '2.7 bar' },
{ id: 'ps-south', name: 'PS South', x: 36, y: 84, state: 'maintenance', reading: '', description: 'Planned: valve replacement until 16:00.' },
{ id: 'ps-harbour', name: 'PS Harbour', x: 86, y: 76, state: 'offline', reading: 'no data for 14 min' }
],
TURBINES = [];
for (let row = 0; row < 4; row++) {
for (let column = 0; column < 5; column++) {
const n = row * 5 + column + 1;
TURBINES.push({
id: 'wtg-' + n,
name: 'WTG ' + String(n).padStart(2, '0'),
x: 10 + column * 19 + (row % 2) * 6,
y: 16 + row * 22,
state: n === 7 ? 'alarm' : n === 14 ? 'warning' : n === 18 ? 'offline' : 'normal',
alarms: n === 7 ? 1 : n === 14 ? 1 : 0,
reading: n === 18 ? '' : (1.2 + ((n * 37) % 20) / 10).toFixed(1) + ' MW'
});
}
}
const show = () => {
network.sites = SITES.map((site) => Object.assign({}, site));
list.sites = SITES.map((site) => Object.assign({}, site));
};
show();
farm.showLabels = false;
farm.sites = TURBINES;
network.addEventListener('siteSelect', function (event) {
note.textContent = 'Selected ' + SITES.find((site) => site.id === event.detail.id).name + '.';
});
network.addEventListener('siteOpen', function (event) {
note.textContent = 'Open the screen of ' + SITES.find((site) => site.id === event.detail.id).name + '.';
});
document.getElementById('trip').addEventListener('click', function () {
Object.assign(SITES.find((site) => site.id === 'ps-east'), { state: 'alarm', alarms: 3, reading: '0.4 bar', description: 'Both pumps tripped: supply to Eastfield on the reservoir.' });
show();
note.textContent = 'PS East is in alarm.';
});
/* ── On the map of the world: a water utility round Linz, its readings pushed as they change ── */
const world = document.getElementById('world'),
worldNote = document.getElementById('worldNote'),
WORLD = [
{ id: 'wtp-linz', name: 'Linz treatment works', lat: 48.2885, lon: 14.3413, state: 'normal', reading: 612, unit: 'm³/h', description: 'Two filter lines of three in service.', href: 'https://en.wikipedia.org/wiki/Water_treatment' },
{ id: 'ps-wels', name: 'PS Wels West', lat: 48.1612, lon: 13.9921, state: 'normal', reading: 3.9, unit: 'bar' },
{ id: 'ps-traun', name: 'PS Traun', lat: 48.2203, lon: 14.2392, state: 'alarm', alarms: 2, reading: 0.9, unit: 'bar', description: 'Pump 2 tripped on overcurrent.' },
{ id: 'res-poest', name: 'Pöstlingberg reservoir', lat: 48.3240, lon: 14.2585, state: 'warning', alarms: 1, reading: 36, unit: '%', description: 'Level below the refill band.' },
{ id: 'ps-enns', name: 'PS Enns', lat: 48.2147, lon: 14.4750, state: 'normal', reading: 4.2, unit: 'bar' },
{ id: 'ps-steyr', name: 'PS Steyr', lat: 48.0427, lon: 14.4213, state: 'maintenance', reading: 'valve work', description: 'Planned until 16:00.' },
{ id: 'bs-leonding', name: 'Booster Leonding', lat: 48.2605, lon: 14.2426, state: 'normal', reading: 2.8, unit: 'bar' },
{ id: 'ps-eferding', name: 'PS Eferding', lat: 48.3084, lon: 14.0224, state: 'offline', reading: 'no data' }
];
world.sites = WORLD;
world.selected = 'ps-traun';
/* the readings as the outstations send them: only what changed, by site and field */
setInterval(function () {
const record = {};
WORLD.forEach(function (site) {
if (typeof site.reading === 'number' && site.state !== 'offline') {
site.reading = Math.round((site.reading * (1 + (Math.random() - 0.5) * 0.02)) * 100) / 100;
record[site.id + '.reading'] = site.reading;
}
});
world.push(record);
}, 2000);
document.getElementById('tripWorld').addEventListener('click', function (event) {
const tripped = event.target.textContent.indexOf('Trip') === 0;
world.push({ 'ps-wels.state': tripped ? 'alarm' : 'normal', 'ps-wels.alarms': tripped ? 1 : 0 });
event.target.textContent = tripped ? 'Reset PS Wels West' : 'Trip PS Wels West';
worldNote.textContent = tripped ? 'PS Wels West is in alarm: pushed as { "ps-wels.state": "alarm" }.' : 'PS Wels West is back to normal.';
});
document.getElementById('wheelAlways').addEventListener('change', function (event) {
world.wheelZoom = event.target.checked ? 'always' : 'ctrl';
});
world.addEventListener('mapMove', function (event) {
worldNote.textContent = 'Zoom ' + event.detail.zoom + ', centred on ' + event.detail.center.lat.toFixed(4) + ', ' + event.detail.center.lon.toFixed(4) + '.';
});
world.addEventListener('siteOpen', function (event) {
worldNote.textContent = 'Open the screen of ' + WORLD.find((site) => site.id === event.detail.id).name + '.';
});
document.getElementById('restore').addEventListener('click', function () {
Object.assign(SITES.find((site) => site.id === 'ps-east'), { state: 'normal', alarms: 0, reading: '4.1 bar', description: '' });
show();
note.textContent = 'PS East is back to normal.';
});
});
});
}
};
</script>
<style>
/* The map spans a supervisor's screen. */
.demo-row {
display: flex;
flex-wrap: wrap;
gap: 24px;
align-items: flex-start;
}
smart-site-map {
width: 900px;
max-width: 100%;
}
/* The picture takes the theme's colours: muted, so the sites are what stands out. */
.region .lake,
.region .river {
fill: none;
stroke: color-mix(in srgb, var(--smart-primary) 30%, var(--smart-surface));
stroke-width: 26;
stroke-linecap: round;
}
.region .lake {
fill: color-mix(in srgb, var(--smart-primary) 30%, var(--smart-surface));
stroke: none;
}
.region .river.thin {
stroke-width: 12;
}
.region .town {
fill: color-mix(in srgb, var(--smart-text-color) 7%, var(--smart-surface));
}
.region .road {
fill: none;
stroke: var(--smart-border);
stroke-width: 8;
stroke-linecap: round;
stroke-linejoin: round;
}
.region .road.minor {
stroke-width: 4;
}
.region text {
fill: var(--smart-text-tertiary-color);
font-size: 28px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
/* ── 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 water network
- On the map of the world
- The same sites as a table
- Without a picture