Industrial - IoT sensors
Live sensor floor: threshold-driven status, sparkline trends, group by line.
A live, editable Svelte 5 data grid example from the SvGrid gallery (Real-time & Streaming). See the SvGrid documentation for the full API.
About this example
A factory-floor sensor dashboard in the Svelte 5 data grid: about 120 readings across four production lines tick every 700 ms. Each row carries threshold bands, the status column is computed live against them, the Trend column is an inline SVG sparkline of the last 24 readings, and the grid groups by line, sensor type or status with row summaries showing sensor counts per group. Updates use the $state.raw swap-the-array pattern.
A factory-floor sensor dashboard: ~120 readings across four production lines, ticking every 700 ms. Each row carries threshold bands (critical-low / warn-low / warn-high / critical-high) and the status column is computed from the live reading against those bands. The Trend column is an inline SVG sparkline of the last 24 readings.
Showcases:
- Live updates with
$state.rawswap-the-array pattern - Threshold-driven status badges
- SVG sparklines as a custom cell
- Group by line / sensor type / status
- Row summaries (sensor count per group)
Imports, features and API used
Imports: @svgrid/grid
Table features registered: rowSortingFeature, columnFilteringFeature, columnGroupingFeature, rowExpandingFeature
SvGridApi methods called: api.setGroupBy()
Frequently asked questions
How is the status computed?
Each sensor row holds critical-low, warn-low, warn-high and critical-high bands; a function compares the live reading with them and the status cell renders the resulting badge. Grouping by status reflects the live values.
How is the trend drawn?
Each row keeps its last 24 readings in an array, and the Trend column's cell snippet renders an SVG polyline from them; only rows whose reading changed get a new path.
Does grouping survive the ticks?
Yes. Grouping is grid state set through api.setGroupBy, and each new data array is regrouped, so a sensor whose status changes moves to the right group.
Related articles
- Building an IoT Sensor Dashboard in Svelte - How to build a live IoT sensor dashboard with SvGrid - high-frequency updates, sparkline trends, threshold alerts, and stale-device detection that all stay smooth at scale.
- Building a Real-Time Trading Grid in Svelte - How to wire a WebSocket tick feed into SvGrid without dropping frames - rAF batching, stable row identity, flash animations, and the exact patterns that scale past 150 ticks per second.
- Building a Logistics / Fleet Tracking Grid in Svelte - How to build a live fleet operations grid with real-time telemetry updates, expandable trip history, and exception-first row styling.
Source code (14-industrial.svelte)
<script lang="ts">
/**
* 14. Industrial - IoT sensor floor
* ---------------------------------
* A factory-floor sensor dashboard: ~120 readings across four production
* lines, ticking every 700 ms. Each row carries threshold bands
* (critical-low / warn-low / warn-high / critical-high) and the status
* column is computed from the live reading against those bands. The Trend
* column is an inline SVG sparkline of the last 24 readings.
*
* Showcases:
* - Live updates with `$state.raw` swap-the-array pattern
* - Threshold-driven status badges
* - SVG sparklines as a custom cell
* - Group by line / sensor type / status
* - Row summaries (sensor count per group)
*/
import {
SvGrid,
tableFeatures,
rowSortingFeature,
columnFilteringFeature,
columnGroupingFeature,
rowExpandingFeature,
renderSnippet,
type ColumnDef,
type SvGridApi,
} from '@svgrid/grid'
const features = tableFeatures({
rowSortingFeature,
columnFilteringFeature,
columnGroupingFeature,
rowExpandingFeature,
})
type SensorType = 'Temperature' | 'Pressure' | 'Vibration' | 'Flow' | 'RPM' | 'Current' | 'Humidity'
type Status = 'Normal' | 'Warning' | 'Critical'
type Sensor = {
id: string
type: SensorType
line: string
location: string
reading: number
unit: string
setpoint: number
criticalLow: number
warnLow: number
warnHigh: number
criticalHigh: number
status: Status
lastUpdate: string
history: number[]
}
const LINES = ['Line A', 'Line B', 'Line C', 'Line D']
const LOCATIONS_PER_LINE = ['Boiler', 'Press', 'Conveyor', 'Mixer', 'Furnace', 'Pump', 'Compressor', 'Reactor']
type SensorSpec = {
type: SensorType
unit: string
setpoint: number
band: number // ±warn band around setpoint
critBand: number // ±critical band (must be ≥ band)
noise: number // random-walk step size
}
const SENSOR_SPECS: SensorSpec[] = [
{ type: 'Temperature', unit: '°C', setpoint: 180, band: 18, critBand: 32, noise: 1.4 },
{ type: 'Pressure', unit: 'bar', setpoint: 12, band: 1.5, critBand: 3.0, noise: 0.18 },
{ type: 'Vibration', unit: 'mm/s',setpoint: 3, band: 1.2, critBand: 2.8, noise: 0.12 },
{ type: 'Flow', unit: 'L/s', setpoint: 45, band: 6, critBand: 12, noise: 0.6 },
{ type: 'RPM', unit: 'rpm', setpoint: 1450, band: 80, critBand: 180, noise: 8 },
{ type: 'Current', unit: 'A', setpoint: 22, band: 3, critBand: 6, noise: 0.25 },
{ type: 'Humidity', unit: '%RH', setpoint: 48, band: 8, critBand: 14, noise: 0.8 },
]
// --- seeded PRNG so the demo's initial state is reproducible.
let prngState = 0xC0FFEE99
function rand(): number {
prngState = (prngState * 1664525 + 1013904223) >>> 0
return prngState / 0xFFFFFFFF
}
function pick<T>(arr: readonly T[]): T { return arr[Math.floor(rand() * arr.length)]! }
function statusFor(reading: number, sensor: Pick<Sensor, 'criticalLow' | 'warnLow' | 'warnHigh' | 'criticalHigh'>): Status {
if (reading <= sensor.criticalLow || reading >= sensor.criticalHigh) return 'Critical'
if (reading <= sensor.warnLow || reading >= sensor.warnHigh) return 'Warning'
return 'Normal'
}
function round(n: number, places: number): number {
const p = 10 ** places
return Math.round(n * p) / p
}
function makeSensors(): Sensor[] {
const out: Sensor[] = []
const now = new Date()
for (const line of LINES) {
for (let i = 0; i < 30; i += 1) {
const spec = pick(SENSOR_SPECS)
const loc = `${pick(LOCATIONS_PER_LINE)} ${1 + Math.floor(rand() * 9)}`
// Bias initial reading to be near setpoint, sometimes nudged into warn.
const offset = (rand() - 0.5) * spec.band * 1.4
const reading = round(spec.setpoint + offset, 2)
const sensor: Sensor = {
id: `${line.replace(/[^A-Z]/g, '')}-${(i + 1).toString().padStart(3, '0')}`,
type: spec.type,
line,
location: loc,
reading,
unit: spec.unit,
setpoint: spec.setpoint,
criticalLow: round(spec.setpoint - spec.critBand, 2),
warnLow: round(spec.setpoint - spec.band, 2),
warnHigh: round(spec.setpoint + spec.band, 2),
criticalHigh: round(spec.setpoint + spec.critBand, 2),
status: 'Normal',
lastUpdate: now.toISOString().slice(11, 19),
// Seed history with 24 nearby values.
history: Array.from({ length: 24 }, () =>
round(spec.setpoint + (rand() - 0.5) * spec.band * 0.9, 2)
),
}
sensor.status = statusFor(sensor.reading, sensor)
out.push(sensor)
}
}
return out
}
function specFor(sensor: Sensor): SensorSpec {
return SENSOR_SPECS.find((s) => s.type === sensor.type)!
}
let sensors = $state.raw<Sensor[]>(makeSensors())
let paused = $state(false)
let tickIntervalMs = $state(700)
function tick() {
const now = new Date().toISOString().slice(11, 19)
sensors = sensors.map((s) => {
// Update only ~60% of sensors per tick - keeps the dashboard alive
// without every row flashing in unison.
if (rand() < 0.4) return s
const spec = specFor(s)
// Mean-reverting random walk toward setpoint. ~5% of ticks inject a
// bigger spike so the status badges actually transition.
const drift = (s.setpoint - s.reading) * 0.04
const spike = rand() < 0.05 ? (rand() < 0.5 ? -1 : 1) * spec.band * 0.9 : 0
const reading = round(s.reading + drift + spike + (rand() - 0.5) * spec.noise, 2)
const history = s.history.length >= 24 ? [...s.history.slice(1), reading] : [...s.history, reading]
return {
...s,
reading,
status: statusFor(reading, s),
lastUpdate: now,
history,
}
})
}
$effect(() => {
if (paused) return
const id = setInterval(tick, tickIntervalMs)
return () => clearInterval(id)
})
// ----- summary counts for the toolbar
const counts = $derived.by(() => {
let normal = 0, warning = 0, critical = 0
for (const s of sensors) {
if (s.status === 'Normal') normal += 1
else if (s.status === 'Warning') warning += 1
else critical += 1
}
return { normal, warning, critical }
})
// ----- grouping controls
let api = $state<SvGridApi<typeof features, Sensor> | null>(null)
let groupBy = $state<string[]>(['line'])
function applyGroup(by: string[]) {
groupBy = by
api?.setGroupBy(by)
}
// ----- sparkline path builder
function sparkPath(values: number[], width: number, height: number): string {
if (!values.length) return ''
let min = Infinity, max = -Infinity
for (const v of values) { if (v < min) min = v; if (v > max) max = v }
const range = max - min || 1
const step = width / Math.max(values.length - 1, 1)
let d = ''
for (let i = 0; i < values.length; i += 1) {
const x = i * step
const y = height - ((values[i]! - min) / range) * height
d += i === 0 ? `M${x.toFixed(1)},${y.toFixed(1)}` : ` L${x.toFixed(1)},${y.toFixed(1)}`
}
return d
}
</script>
{#snippet ReadingCell(props: { sensor: Sensor })}
<span class="iot-reading iot-reading-{props.sensor.status.toLowerCase()}">
{props.sensor.reading.toLocaleString(undefined, { minimumFractionDigits: 1, maximumFractionDigits: 2 })}
<span class="iot-unit">{props.sensor.unit}</span>
</span>
{/snippet}
{#snippet StatusBadge(props: { value: Status })}
<span class="iot-badge iot-badge-{props.value.toLowerCase()}">{props.value}</span>
{/snippet}
{#snippet SetpointCell(props: { sensor: Sensor })}
<span class="iot-setpoint">
{props.sensor.setpoint}{props.sensor.unit}
<span class="iot-band">
±{(props.sensor.warnHigh - props.sensor.setpoint).toFixed(1)}
</span>
</span>
{/snippet}
{#snippet SparklineCell(props: { sensor: Sensor })}
{@const stroke = props.sensor.status === 'Critical' ? '#dc2626' : props.sensor.status === 'Warning' ? '#ca8a04' : '#2563eb'}
<svg viewBox="0 0 100 24" preserveAspectRatio="none" class="iot-spark" aria-hidden="true">
<path d={sparkPath(props.sensor.history, 100, 24)} fill="none" stroke={stroke} stroke-width="1.4" />
</svg>
{/snippet}
<section class="flex flex-col flex-1 min-h-0 gap-3">
<div class="flex flex-wrap items-center gap-3 text-sm shrink-0">
<button
type="button"
onclick={() => (paused = !paused)}
class="iot-btn rounded px-3 py-1"
>
{paused ? '▶ Resume' : '⏸ Pause'}
</button>
<label class="iot-label flex items-center gap-2">
Tick:
<select
bind:value={tickIntervalMs}
class="iot-select rounded px-2 py-1"
>
<option value={250}>250 ms</option>
<option value={500}>500 ms</option>
<option value={700}>700 ms</option>
<option value={1500}>1.5 s</option>
</select>
</label>
<span class="ml-3 font-medium">Group:</span>
<button onclick={() => applyGroup([])} class="iot-btn rounded px-3 py-1 {groupBy.length === 0 ? 'iot-btn-on' : ''}">None</button>
<button onclick={() => applyGroup(['line'])} class="iot-btn rounded px-3 py-1 {groupBy.join() === 'line' ? 'iot-btn-on' : ''}">Line</button>
<button onclick={() => applyGroup(['type'])} class="iot-btn rounded px-3 py-1 {groupBy.join() === 'type' ? 'iot-btn-on' : ''}">Type</button>
<button onclick={() => applyGroup(['status'])} class="iot-btn rounded px-3 py-1 {groupBy.join() === 'status' ? 'iot-btn-on' : ''}">Status</button>
<span class="ml-auto inline-flex items-center gap-3 tabular-nums">
<span class="inline-flex items-center gap-1.5"><span class="iot-dot iot-dot-normal"></span>{counts.normal}</span>
<span class="inline-flex items-center gap-1.5"><span class="iot-dot iot-dot-warning"></span>{counts.warning}</span>
<span class="inline-flex items-center gap-1.5"><span class="iot-dot iot-dot-critical"></span>{counts.critical}</span>
</span>
</div>
<div class="flex-1 min-h-0">
<SvGrid responsive={true}
columnResize
data={sensors}
columns={[
{ field: 'id', header: 'ID', editorType: 'text', width: 110 },
{ field: 'line', header: 'Line', editorType: 'text', width: 110 },
{ field: 'type', header: 'Type', editorType: 'text', width: 130 },
{ field: 'location', header: 'Location', editorType: 'text', width: 140 },
{
field: 'reading', header: 'Reading', editorType: 'number', width: 130,
cell: (ctx) => renderSnippet(ReadingCell, { sensor: ctx.row.original }),
},
{
id: 'setpoint', header: 'Setpoint', editorType: 'number', width: 130,
fieldFn: (row) => row.setpoint,
cell: (ctx) => renderSnippet(SetpointCell, { sensor: ctx.row.original }),
},
{
field: 'status', header: 'Status', editorType: 'text', width: 110,
cell: (ctx) => renderSnippet(StatusBadge, { value: ctx.row.original.status }),
},
{ field: 'lastUpdate', header: 'Updated', editorType: 'text', width: 110 },
{
id: 'trend', header: 'Trend (24)', fieldFn: () => '', width: 140,
cell: (ctx) => renderSnippet(SparklineCell, { sensor: ctx.row.original }),
},
] satisfies ColumnDef<typeof features, Sensor>[]}
features={features}
filterMode="menu"
selectionMode="cell"
enableInlineEditing={false}
enableCellSelection={true}
enableRowSummaries={true}
rowHeight={36}
containerHeight="100%"
fitColumns={true}
onApiReady={(next) => {
api = next
next.setGroupBy(groupBy)
// Expand the first line so the demo opens with rows visible.
queueMicrotask(() => next.setRowExpanded(`line:${LINES[0]}`, true))
}}
/>
</div>
</section>
<style>
/* Toolbar chrome follows the active grid theme; the status colors below
stay fixed because they carry meaning, not styling. */
.iot-btn {
border: 1px solid var(--sg-border, #cbd5e1);
background: var(--sg-bg, transparent);
color: var(--sg-fg, inherit);
}
.iot-btn:hover { background: var(--sg-row-hover-bg, #f1f5f9); }
.iot-btn-on { background: var(--sg-bg-subtle, var(--sg-header-bg, #e2e8f0)); }
.iot-label { color: var(--sg-muted, #475569); }
.iot-select {
border: 1px solid var(--sg-border, #cbd5e1);
background: var(--sg-input-bg, var(--sg-bg, transparent));
color: var(--sg-fg, inherit);
}
.iot-reading {
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.iot-unit {
margin-left: 4px;
font-weight: 400;
color: var(--sg-muted, #94a3b8);
font-size: 11px;
}
.iot-reading-normal { color: var(--sg-fg, inherit); }
.iot-reading-warning { color: #b45309; }
.iot-reading-critical { color: #b91c1c; }
:global([data-theme='dark']) .iot-reading-warning { color: #fbbf24; }
:global([data-theme='dark']) .iot-reading-critical { color: #f87171; }
.iot-setpoint { font-variant-numeric: tabular-nums; }
.iot-band { margin-left: 6px; color: var(--sg-muted, #94a3b8); font-size: 11px; }
.iot-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
}
.iot-badge-normal { background: #dcfce7; color: #166534; }
.iot-badge-warning { background: #fef3c7; color: #92400e; }
.iot-badge-critical { background: #fee2e2; color: #b91c1c; }
:global([data-theme='dark']) .iot-badge-normal { background: rgba(34, 197, 94, 0.18); color: #4ade80; }
:global([data-theme='dark']) .iot-badge-warning { background: rgba(245, 158, 11, 0.18); color: #fbbf24; }
:global([data-theme='dark']) .iot-badge-critical { background: rgba(239, 68, 68, 0.18); color: #f87171; }
.iot-dot {
width: 8px; height: 8px; border-radius: 50%; display: inline-block;
}
.iot-dot-normal { background: #16a34a; }
.iot-dot-warning { background: #ca8a04; }
.iot-dot-critical { background: #dc2626; }
.iot-spark {
width: 100%;
height: 22px;
display: block;
}
</style>More Real-time & Streaming examples
- Stock market - live - WebSocket-style ticking feed. Cells flash on up/down ticks, pause control, throttle.
- Industrial dashboard - KPI cards plus live line-status and active-alarms grids, on a 2-second tick.
- Real-time / streaming - WebSocket-style live order stream with delta merge, out-of-order safety, pause / backlog, disconnect-reconnect, throughput slider.
- Real-time collaboration - Presence (who is here + where their cursor is) and live edits (a change in one client lands in every other) over a pluggable transport. createCollaboration + broadcastChannelTransport sync cursors and edits across tabs with zero backend; swap the transport for a WebSocket to go cross-machine. Also the substrate for multiple AI agents editing one grid.
- Transaction API (batched) - api.applyTransaction({ add, update, remove }) applies a batch of row mutations in ONE data update - the high-frequency streaming path. update and remove-by-id match on getRowId; remove also accepts row refs. Live order book ticking via batched transactions.