system-ui-compute & system-ui
Two packages for showing a system, split so that what is shown is worked out once and drawn anywhere:
| Package | Published as | What it is |
|---|---|---|
system-ui-compute | @wity-ai/system-ui-compute | Zero-DOM compute. Isomorphic; tested with node --test |
system-ui | @wity-ai/system-ui | Components that draw what system-ui-compute returns. Muffin components — the host page provides the Muffin global |
Inside the monorepo, system-ui-compute is also reachable as wity-system/ui-compute.
system-ui-compute
import {
computeScene, withHidden, toggleHidden, computeAspectScene, aspectLayout, memberLook, isDrawn, blockDims, ASSET_RADIUS, LABEL_OFFSET, ARROW_SIZE, BLOCK,
ASPECT_LAYOUTS, MEMBER_LOOK, FLOW_STROKE, LANE_TONES,
NODE_SHAPES, BOXED_SHAPES, LINE_ROUTES, LINE_ARROWS, BOX, LINE_LABEL, validateTypeStyle, shapeOf, routeOf, arrowsOf,
boxDims, boxOutline, boxEdgeOffset, boxLabelLines, elbowRoute, trimRoute, routeLabelPoint, lineLabel, routePath,
lightenHex, isLightColor, strongOf, typeTints, strokeHoverOf, circleColors, iconGlyph,
REGION_FILL, REGION_STROKE, REGION_STROKE_WIDTH, regionLabelAnchor,
contentsText, summaryView, thresholdText, wavesView, heatView, stripAssets, assetsOverAt, thresholdsByFeature, stress,
} from '@wity-ai/system-ui-compute';Scene
computeScene resolves a system graph into a serializable render scene. Every renderer (authoring, viewer, print) paints the same scene, so positions, sizes, colours and annotations match by construction.
computeScene(system, opts?) → { bounds, nodes, edges, regions }| Option | Description |
|---|---|
getAssetType(typeName) | Defaults to system.getAssetType |
getConnectionType(typeName) | Defaults to system.getConnectionType |
positionOf(asset) → { x, y } | Override positioning (e.g. a map projection). Defaults to the asset's data.x / data.y |
hidden | What the reader hid from the legend (a Hidden: { assetTypes, connectionTypes, regions }, each a list) — applied with withHidden |
bounds—{ minX, minY, maxX, maxY }over the node positions.nodes—{ uid, type, x, y, shape, fill, fillLight, fillLighter, icon, label },shapeone ofNODE_SHAPES(below). Acirclenode addsradius; ablocknode addsdims: { w, h },itemsandcontentColor; a boxed node (rect·rounded·pill·diamond) addsdims,outline(an SVG path centred on the node) andlabelLines(its label broken to fit inside). A node with annotations addsannotations: [{ text, status, dx, dy, chipW, chipH }].edges—{ uid, type, srcUid, targetUid, from, to, points, stroke, strokeHover, dashed, carriesFlow, arrow, arrowStart }.pointsis the whole route (two points when it runs straight, the corners of anelbow);from/toare its ends, offset to each node's edge, with room for an arrowhead at each end that has one (arrowat the target,arrowStartat the source). A connection with alabeladdslabel: { text, x, y, w, h }, its plate on the line.regions— the system's regions, as returned bysystem.getRegions(), each with the colours it is drawn in: its ownmeta.fill/stroke/strokeWidth, orREGION_FILL/REGION_STROKE/REGION_STROKE_WIDTH.regionLabelAnchor(points)is where its name is written.legend—{ assetTypes: [{ type, label, fill, icon, shape, count, hidden }], connectionTypes: [{ type, label, stroke, dashed, count, hidden }], regions: [{ uid, label, fill, stroke, hidden }] }: what is drawn, in the order it first appears.view—{ legend, focus }: the system's own setting (getView) — the legend'open'or'collapsed', andfocus, the bounds of what it is focused on (null: the whole map). A surface fits its screen toview.focus ?? bounds.
How a type is drawn: its style
A type's meta chooses from one drawing vocabulary. It changes how the type is drawn and nothing else: system-core and system-sim never read it.
| Field | On | Values |
|---|---|---|
shape | asset type | NODE_SHAPES: circle (default — an icon in a disc, its label below) · block (a titled card with a list) · rect · rounded · pill · diamond (BOXED_SHAPES: an outline with the label inside) |
width, height | asset type, boxed shape | its size, within BOX.minWidth–BOX.maxWidth and BOX.minHeight–BOX.maxHeight (default BOX.width × BOX.height; a diamond's BOX.diamond) |
fill, fillLight, icon | asset type | its colour, its interior, its icon — a name from the host's icon set, or a glyph (an emoji) written as it is (iconGlyph) |
route | connection type | LINE_ROUTES: straight (default) · elbow (right angles) |
arrow | connection type | LINE_ARROWS: end (default) · none · both |
stroke, strokeHover, dashed | connection type | its colour, its colour under the pointer, whether it is dashed |
A connection's own label (in its data) is written on its line. validateTypeStyle(kind, meta) → string[] says what in a type's style is not in the vocabulary (kind: 'asset' or 'connection'; fields it does not own are not looked at); shapeOf, routeOf and arrowsOf read a type's choice, falling back to the default for none or an unknown one.
The geometry every renderer shares, so lines meet shapes and labels break the same way everywhere:
boxDims(shape, typeDef) → { w, h } // its meta.width / height, held to the limits, else the default
boxOutline(shape, { w, h }) → SVG path // centred on (0, 0)
boxEdgeOffset(shape, { w, h }, nx, ny) → number // how far its edge is from its centre along a unit direction
boxLabelLines(shape, dims, text) → string[] // the label in the lines that fit; what does not ends in '…'
elbowRoute(src, tgt) → points | null // a right-angle route; null when too close for a corner
trimRoute(points, by, end = 'end' | 'start') // leave room for an arrowhead
routePath(points) → SVG path
routeLabelPoint(points) → { x, y } // the middle of the route
lineLabel(text, points) → { text, x, y, w, h } | null // a line's label plate (LINE_LABEL sizes it)Colours: circleColors(asset, typeDef) — a circle's { fill, fillLight, fillLighter }; given a light fill and no interior of its own, the light colour becomes the interior and the outline takes strongOf(fill), a strong colour of the same hue. typeTints(fill) → { fillLight, fillLighter }, strokeHoverOf(stroke), lightenHex(hex, amount), isLightColor(hex).
blockDims(asset, typeDef) → { w, h } gives a block node's size.
contentsText(system.getContents()) → string says what a system holds in one line, for a header: '8 assets · 10 connections · 6 types · 1 state machine' — only what it has is named (a system that is only a syntax tree reads '1 syntax tree'), an aspect in its formalism's words.
withHidden(scene, hidden) → scene leaves out what the reader hid: the assets of a hidden type, the connections of a hidden type or with a hidden asset at either end, the hidden regions. The legend still lists everything, each entry with hidden true or false, so it can be shown again; bounds stay those of the whole map, so nothing moves. With nothing hidden the scene is returned as it is. toggleHidden(hidden, kind, key) → Hidden hides one legend entry or shows it again (kind: 'assetTypes' · 'connectionTypes' · 'regions').
An aspect as a scene
computeAspectScene(system, aspectId) → { aspect, words, bounds, nodes, edges, regions, legend, view, findings } | nullOne aspect of a system as the same scene shape, so one painter draws both. null when the system has no such aspect, or its formalism declares no layout. Everything is read from the formalism's declaration: each kind of member's look, each kind of line's look, and layout — 'rows' or 'tree'. A link the formalism marks as a lane (a process's performedBy) frames the members that name the same asset. findings is what breaks the formalism's rules; a node named by one carries problems (how many).
aspectLayout(aspect, declaration, kindOf?) → Map<memberId, { x, y }> places its members (ASPECT_LAYOUTS: 'rows', 'tree'); memberLook(member, system, declaration) → { kind, shape, w, h, fill, label } | null is how one is drawn (MEMBER_LOOK: the look a kind has when its declaration gives none); isDrawn(declaration) — whether a formalism declares a layout. FLOW_STROKE is the colour of its lines, LANE_TONES the tints of its lanes.
What a running system shows
summaryView({ assets, featuresOf, events, stateOf, history, now?, maxCharts? = 3, maxSeries? = 6 }) → { tiles, charts }
thresholdText({ op, value }) → string // '> 0.9'What a reader should see first of a running system: for each signal an event watches, a tile with the worst value on the map and how many assets are past the line, and a chart of how it has moved on the assets that matter most. The per-asset detail is the signal strip's.
Signal strip
What a strip of live signals under a running system shows, worked out from the simulation's recorded history. Two views over one time window:
- waves — a few rows, one signal each: its line, its threshold, the spans spent past it, and marks where events fired and decisions were made.
- heat — every asset that has a watched signal as one row of cells: how close the signal was to its threshold, bucket by bucket.
All inputs are plain data or functions, so this never touches the simulation itself:
| Input | Shape |
|---|---|
assets | [{ uid, label, type }] |
featuresOf | (type) → { [featureId]: { range?: [lo, hi] } } |
events | the applied dynamics' events: { [name]: { kind: 'threshold', feature, op, value } | … } |
history | (uid, featureId) → { times, values } | null (simulation time, ms) |
marks | [{ t, uid, kind: 'event' | 'decision', name, status? }] |
now | current simulation time (ms) |
windowMs | how far back the strip looks (default 120 000) |
Only threshold events are read as thresholds.
wavesView({ assets, featuresOf, events, history, marks?, now, windowMs?, pinned?, max? })
→ { t0, t1, rows: [{ uid, label, featureId, range, last, threshold, points, over, marks }] }
heatView({ assets, featuresOf, events, history, marks?, now, windowMs?, buckets? })
→ { t0, t1, buckets, rows: [{ uid, label, featureId, cells, peak, marks }] }Waves. points are [x, y] pairs in 0 … 1 (y up); over are [x0, x1] spans past the threshold; threshold is { y, value, op } or null; marks are { x, kind, name, status? }. Which assets are shown, at most max (default 4): the pinned ones first, then those that fired events (most recent first), and — while there is room — the assets closest to a threshold right now, so the strip shows something before anything has fired. A pinned asset shows all its signals; any other shows the ones a threshold watches. An event marks the row of the signal it watches; a decision marks every row of its asset.
Heat. cells has buckets entries (default 60); each is the highest stress in its bucket, or null where there is no sample. A signal sampled less often than the cells are wide holds its value between its first and last sample. Rows are ordered hottest first (peak).
Helpers:
thresholdsByFeature(events) → { [featureId]: [{ name, op, value }] }
stress(value, { op, value: threshold }, range?) → number // 1 = at the threshold, above 1 = past it
stripAssets({ assets, marks?, pinned?, max? }) → uid[] // pinned, then fired — most recent first
assetsOverAt({ assets, featuresOf, events, history, t }) → uid[] // past a threshold at sim time tsystem-ui
<signal-strip>
A strip docked under a running system map, with the two views above (labelled Signals and Whole system), a collapse toggle, and a "simulated" badge when the signals come from generators rather than telemetry.
import { SignalStrip } from '@wity-ai/system-ui';
SignalStrip.compose();
strip.configure({
assets: () => [{ uid, label, type }],
featuresOf: (type) => ({ utilization: { range: [0, 1] } }),
events: () => appliedDynamics.events,
history: (uid, featureId) => sim.getHistory(uid, featureId),
now: () => sim.simTime,
onSelect: (uid) => selectOnTheMap(uid),
simulated: true,
});| Method | Description |
|---|---|
configure(source) | Set the data source (above) and redraw |
mark({ t, uid, kind, name, status? }) | Record an event or a decision as it happens |
pin(uid) / unpin(uid) | Keep an asset in view / stop. pinned returns the current list |
focus(uid, t) | Point at a moment: keeps uid in view, switches to the Signals view and puts a marker at simulation time t. focus(null) clears it |
setView('waves' | 'heat') | Switch view |
refresh() | Redraw — call on the simulation's tick, throttled |
clear() | A new run: forget the marks (pins stay) |
Painting a scene
import { paintScene, AspectView } from '@wity-ai/system-ui';
paintScene(svgGroup, computeScene(sys), { arrowId, renderIcon: (name, size) => svgMarkup, projectRegionPoint?, emphasis? });
const view = new AspectView(container, sys, aspectId); // one aspect: its scene, painted; a draft says what breaks
view.fit(); // frame the whole drawing in its box → false while the box has no size yet
view.zoomBy(1.25); // zoom about the middle: > 1 closer, < 1 further — for a host's buttons
view.destroy();The one painter, shared by the app and the viewer, so both draw the same thing. renderIcon is the host's icon set (left out, assets are drawn without icons). An AspectView shows any aspect whose formalism declares a layout. The host lays out its box; the drawing states its own size (--se-aspect-w / --se-aspect-h) and is framed to fit the box, again when the box is resized. It moves as the map does: scroll or pinch to zoom, drag the background to pan. A reader explores it: clicking a member keeps it and what leads to it and follows it bright (the system's impact); clicking a second lights the way between them (shortestPath); clicking again, the background or Escape clears. Nothing is changed. The line under the drawing (.se-aspect-hint) says what is shown, in the formalism's words; the picked members carry .se-node--picked.
