Thermal canvas
NewA shared plant board whose press node is wired to a live isometric bed of temperature bars, with teammates’ cursors, a hot spot and a minimap.
thermal-canvas/thermal-canvas.tsxBed temperature
°C · liveHot spot
224°Climit 210
OEE
Line 3 · today
71.4 %OEE
+3.2 ptsAndon
Line 3 · 8 stations
- Cut38 s
- Bend41 s
- Weld-104:12
- Weld-2idle
- Paint34 s
- AssyQC hold
- QC29 s
- Packidle
Weld-1 stopped
04:12Supervisor paged in 1:48
Tightening
Station 4 · M8 bolt · last 20
24.8Nm at 38°
OKTrue position
Hole Ø 8 H7 · 64 parts
0.031mm offset · last 30
Cpk 1.12Line-side stock
Flow rack R3 · 8 bins
2bins to refill
run in 6 minMilk run
Tugger 2 · 18 min loop
- Weld-22 bins3 min
- Paint1 bin7 min
- Assy4 bins11 min
The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.
When to use
- A shared operations board where a team watches one machine’s live readings and talks about them in place: a hot spot, a comment on it, an alert.
- Spatial data that reads best in the shape of the thing: a press bed, a tray, a rack of bins, where a cell’s place matters as much as its value.
- One screen that holds several related nodes (a press, its line, a station, a rack) and lets people fly between them without losing their bearings.
- Readings that need three views of the same numbers: the shape at a glance (3D bars), exact values (a grid), and the trend (a line over time).
When not to use
- A single number or a short list of readings. A metric card or a table says it faster.
- Reports that have to be read exactly or printed. 3D bars hide the back rows; use the grid view or a heatmap alone.
- Hundreds of nodes or a free-form whiteboard. The board is laid out by hand for its seven nodes, with no culling.
- Alerting by itself. The suggested alert is a shortcut on the board; the rule still belongs in a real alerts setting.
- A phone-first tool. Below 540 px it frames one node at a time, which works, but the board is at its best with room.
You can add this component with the shadcn CLI or by hand:
Into components/thermal-canvas of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/thermal-canvas.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.
Usage
import { ThermalCanvas } from './thermal-canvas';export function PressShop() { return ( <ThermalCanvas defaultView="grid" onAlertChange={(alert, on) => console.log(alert, on)} autoplay={false} /> );}Each state below is a live instance, locked so you can study it.
The pieces it is built from, each with its measurements read live.
A collaborative canvas for a press shop. The selected node, Press 2 · thermal, is wired to its Bed temperature widget: 48 isometric bars coloured by temperature band, drifting live, with a hot spot over the limit. Switch the widget to a flat grid or a line over time, hover any cell for its value, add the suggested alert, comment, zoom, drag the canvas, or fly through the node switcher and the minimap to the line’s other nodes: OEE and andon, tightening and true position, line-side stock and the milk run.
- Every 3D picture is plain SVG: a box is three paths (top, left face, right face) projected at 30 degrees, painted back to front so nearer bars cover the ones behind. Their dotted faces are SVG patterns, one per colour and face, skewed with the face.
- The canvas is a world under a camera: one translate-and-scale on the world. Dragging pans it, Ctrl or a trackpad pinch zooms about the pointer, the zoom buttons step it, and the minimap and the node switcher fly it with an eased frame-by-frame tween. The camera lives in a ref, so React does not render while it moves; the grid, the wire to the widget, the zoom readout and the minimap’s frame follow on the same frame.
- The data ticks once a second while the board is on screen and the tab is visible: each cell eases back to its resting temperature with a little noise, the hot spot breathes around 224 °C, the andon’s stop timer runs, the tugger rolls round its loop and the rack’s bins drain. Everything starts from fixed values and a seeded random source, so server and browser draw the same first frame.
- The bed has three views: 3D bars, a flat grid of the same 48 cells, and the hot spot against the bed’s mean over the last 40 s with the 210 °C limit. Hover or the arrow keys name a cell and its value in a tooltip.
- The board is laid out at 600 px and scaled to its container with CSS zoom, so it stays crisp at any size; below 540 px it rearranges into a narrow board whose camera frames one node at a time and whose suggestion moves under the canvas.
- While on screen and untouched, Ana’s cursor shows the board round: it hovers bars, switches views, adds the suggested alert, flies to the other nodes and back. Its hovers are real pointer events dispatched where the cursor stands, so the board reacts exactly as it would to a mouse; any real input hands it to the reader.
- Every colour is a light-dark() token on the root, so the board follows the page’s color-scheme, or the colorScheme prop; the data colours are the same in both.
| Prop | Type | Default | Description |
|---|---|---|---|
defaultView | '3d' | 'grid' | 'line' | '3d' | The widget’s first view: isometric bars, a flat grid of cells, or a line over the last 40 s. |
defaultNode | 'thermal' | 'oee' | 'andon' | 'tightening' | 'position' | 'stock' | 'milkrun' | 'thermal' | The node the board opens on; the camera starts there. |
live | boolean | true | Whether the data drifts, once a second while the board is on screen. The widget’s menu can also pause it. |
onAlertChange | (alert: string, on: boolean) => void | — | Called when the suggested alert for the nodes in view is added or removed. |
onViewChange | (view: '3d' | 'grid' | 'line') => void | — | Called when the widget’s view changes. |
onNodeChange | (node: NodeId) => void | — | Called when a node is picked in the node switcher or the minimap. |
defaultState | ThermalCanvasState | — | Opens in this state, held until the reader changes it: hover (a cell drawn hovered with its tooltip, by name, as 'C3', in the 3D or grid view), at (in the line view, the moment scrubbed, in seconds ago from 0 to 39), menu ('nodes' | 'more' | 'share'), alert (the suggested alert already added), replies (Ben’s comment open with its two replies) and tool ('widget' | 'select' | 'note' | 'frame'). Turns the demo off; a menu it opens doesn’t take the focus and ignores presses outside until the board is used. |
still | boolean | false | Holds the data on its first frame; the board still answers the reader. Turns the demo off. |
autoplay | boolean | true | While on screen and untouched, Ana’s cursor shows the board round. Any real input hands it to the reader. |
colorScheme | 'light' | 'dark' | 'system' | — | Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one. |
className | string | — | Extra class for the root. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type FocusEvent as ReactFocusEvent, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent,} from 'react';import { AndonCard, arrowKeys, Handles, MilkRunCard, OeeCard, PositionCard, StockCard, TighteningCard,// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useState, type KeyboardEvent } from 'react';import { BANDS, BAND_NAMES, COLS, HISTORY, HOT, LIMIT, ROWS, band, cellName, type Bed,} from './data';import { box, floor, patternId, type Frame, type Paint } from './iso';import styles from './thermal-canvas.module.css';/* * The press's own nodes on the board: the "Bed temperature" widget, drawn three ways (3D bars, * a flat grid, a line over time), the hot spot's card and Ben's comment. *// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useState, type KeyboardEvent, type ReactNode } from 'react';import { seeded } from './data';import { box, floor, patternId, project, wireBox, type Frame, type Paint } from './iso';import { DotPatterns } from './thermal';import styles from './thermal-canvas.module.css';/* * The other nodes on the board, two to a group: the line's OEE and its andon, a station's * tightening curves and a hole's true position, a flow rack's stock and the tugger's milk run. * Every number is invented; the ones that tick (the stop timer, the tugger, the bins) follow the * board's one-second clock, `tick`, so they all pause together. */export type NodeId = 'thermal' | 'oee' | 'andon' | 'tightening' | 'position' | 'stock' | 'milkrun';const PAINTS: Record<string, Paint> = { gray: { top: 'var(--gray-top)', left: 'var(--gray-left)', right: 'var(--gray-right)' }, dark: { top: 'var(--column-left)', left: 'var(--column-left)', right: 'var(--column-right)' }, orange: { top: '#ff9f6a', left: '#fd7831', right: '#b5521f' }, yellow: { top: '#ffe073', left: '#f5cf46', right: '#ad9028' },// Copyright (c) 2026 Bruno Delorence. MIT License./* * Isometric drawing in plain SVG paths, shared by every 3D picture on the board (the bed's bars, * the losses waterfall, the hole's histogram, the flow rack's bins). * * A point on the floor has two coordinates: u runs down and to the right on the screen, v up and * to the right, both at 30 degrees. z is height in px. A box is seen from the front, so three of * its faces show: the top, the face towards the reader on the left (v at its smallest) and the * face towards the reader on the right (u at its largest). *//** tan(30°): how far a step along u or v drops (or climbs) for each px it moves across. */export const ISO = Math.tan(Math.PI / 6);export type Pt = { x: number; y: number };/** A projection: `px` screen px across per unit of u or v, the floor's origin at `ox`, `oy`. */export type Frame = { ox: number; oy: number; px: number };/** Where floor point (u, v), lifted by z px, lands on the screen. */export function project(frame: Frame, u: number, v: number, z = 0): Pt { return { x: frame.ox + (u + v) * frame.px,// Copyright (c) 2026 Bruno Delorence. MIT License./* * The press bed's temperatures: 48 cells, 8 across (columns A–H, A on the right) by 6 deep (rows * 2–7 from the back; row 1 is the bed's clamp strip, which has no sensors), and how they drift * while the board is live. All of it is invented. * * Every cell eases back towards its own resting temperature with a little noise on top, and the * hot spot breathes around 224 °C, so the picture moves without ever wandering off. The numbers * start from fixed values and a seeded random source, so the server and the browser draw the same * first frame. */export const COLS = 8;export const ROWS = 6;/** The bed's limit, °C: any cell above it is an alert. */export const LIMIT = 210;/** Resting temperatures, °C, by row (r: 0 at the front edge) and column (c: 0 at the left). */const REST: number[][] = [ [175, 181, 178, 178, 172, 178, 175, 174], [184, 184, 180, 177, 183, 183, 183, 182], [183, 184, 182, 179, 190, 195, 194, 187], [171, 177, 178, 184, 198, 219, 216, 194],// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. `visible` alone says whether it is on screen in * a visible tab, for work that runs whether or not the demo plays (live data). */export function useAutoplay<T extends HTMLElement>(idle = 6000) { const ref = useRef<T>(null); const [visible, setVisible] = useState(false); const [reduced, setReduced] = useState(true); const [taken, setTaken] = useState(false); const timer = useRef(0); useEffect(() => { const element = ref.current; if (!element) return; let onScreen = false; const update = () => setVisible(onScreen && !document.hidden);/* Thermal canvas (thermal-canvas.tsx): a plant board on a stage. The board is laid out at 600 px wide and scaled to its container with CSS zoom (set from script), so every size below is in the board's own px; under 540 px the narrow board (data-narrow) lays itself out at the real width. Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes the color-scheme it inherits from the page, or the one its colorScheme prop sets (data-color-scheme). The data colours (bands, losses, bins) are the same on both. Memory. The stage is one layout boundary; the canvas clips its world, and the world moves by one transform. Bars, curves and bins are SVG paths, redrawn once a second at most; the cursors move by transforms. */.root { --page: #111312; --board: #1e201f; --canvas: #262827; --grid: rgb(255 255 255 / 0.045); --panel: #161817; --hud: #0e100f; --hud-2: #2d2f2e; --tool-on: #3a3c3b; --comment: #2c2e2d; --card: #1e201f; --card-panel: #262827;- The tools and the widget’s views are labelled radio groups; the node switcher is a button that opens a listbox (arrow keys, Home, End, Enter, Escape), and focus returns to it.
- The bed chart is one focusable group with a spoken summary; arrow keys move between cells (or step through time in the line view) and each step is announced in a polite live region.
- A control that receives focus off screen brings the camera to it, so tabbing through the board never lands on something hidden. Escape closes any menu and drops the current tool.
- Badges, the stop timer and the milk run’s next stop update in polite live regions; adding an alert, copying the link and moving between nodes are announced too.
- Teammates’ cursors, the 3D pictures and the minimap’s drawing are decorative (aria-hidden); their content is in the labels, legends and buttons around them.
- Under reduced motion the camera jumps instead of flying, transitions are off and the demo never plays; the data still updates.
C3 is hot again. Swap the die insert before shift B?