Packages

Truss UI is a small family of npm packages. They share one set of design tokens, so charts, graphs and maps retheme with the rest of your app.

PackageWhat it isDependencies
@ruckc/truss-uiThe core: tokens, fonts, icons and ~90 components.peer: svelte
@ruckc/truss-chartLine and (stacked) area, bar, donut, histogram, scatter and sparkline charts, plus stat tiles, in plain SVG.peer: svelte
@ruckc/truss-graphA node/edge editor and read-only explorer: custom node components, five edge routers, layered, force and radial layouts.peers: svelte, @ruckc/truss-ui
@ruckc/truss-olAn OpenLayers map styled by the tokens.peers: svelte, @ruckc/truss-ui, ol

Charts · @ruckc/truss-chart

Import the palette once, then use the components. Charts are responsive (they fill their container's width), keyboard-readable, and every one has a Table toggle showing the underlying values.

import '@ruckc/truss-chart/charts.css';
import { LineChart, BarChart, DonutChart, Histogram, ScatterChart, Sparkline, StatTile, ColorScale, gradientFrom } from '@ruckc/truss-chart';
<LineChart title="Traffic" {x} series={[{ name: 'Requests', values }]} />
<BarChart categories={['Q1','Q2']} {series} stacked="percent" horizontal showValues />
<Histogram values={latencies} bins={30} bind:range />
<LineChart {x} {series} area stacked="percent" />
<ScatterChart {points} xLabel="CPU" yLabel="Latency" />
<StatTile label="Requests" value={12840} delta={0.12} spark={trend} />
<LineChart {x} {series} band={{ lo, hi, label: 'Forecast range' }} referenceLines={[{ value: 0, label: 'Break-even' }]} xFormat={(d) => `Day ${d}`} yScale="log" height="fill" />
<StatTile size="sm" label="Errors" value={12} />
  • LineChart extras. band is one chart-level { lo, hi, color?, label? } envelope (arrays aligned to x, null leaves a gap) drawn behind the lines, widening the y-domain and listed in the tooltip and table. referenceLines draws emphasised labelled horizontal lines (also in the domain, the accessible name and the table view). zeroBaseline only guarantees 0 is inside the y-domain; it draws no line. xFormat mirrors yFormat for ticks, tooltip and table. height="fill" sizes the plot to its parent via a ResizeObserver, so the parent needs a definite height. xScale/yScale="log" use base-10 axes with ticks at powers of 10; values at or below 0 are ignored (dropped on x, treated as missing on y), stacked is ignored on a log y axis, and a log x axis is ignored for dates.
  • StatTile size. size="sm" uses the --tr-text-xl value size and tighter padding. Set --trc-stat-value-size on the tile or any ancestor to choose the value size directly (default 28px); no :global override is needed.
  • Up to eight series, in a fixed colour order that never cycles. Past eight, fold the tail into “Other”.
  • To colour a series yourself, set color on it (any CSS colour or var(--…)). This bypasses the validated palette, so check contrast and distinguishability yourself. To retheme a palette slot for every chart inside one element instead, override --trc-sN on a wrapper element.
  • BarChart draws labelled reference lines with reference={{ value: 200, label: 'Target' }} (or an array of them; they extend the value axis so they are always in view) and takes details, one string per category, shown in the tooltip and as a Details column in the table view. For pre-binned counts, use BarChart with categories; Histogram is for binning raw values.
  • Continuous scales (heatmaps, choropleths) use the sequential and diverging ramps: --trc-seq-1..7 (one hue, lightness monotonic from low to high) and --trc-div-1..7 (blue to a neutral midpoint --trc-div-4 to orange, colour-blind-safe poles), both with dark-mode values. gradientFrom('sequential' | 'diverging', direction?) builds a CSS linear-gradient from the tokens, and <ColorScale caption="Error rate" low="0%" high="40+%" palette="diverging" mid="20%" /> draws the legend (a role="img" named "Error rate: 0% to 40+%"; pass vertical for a bottom-to-top bar).
  • One value axis only: never dual-axis. Two measures of different scale belong in two charts.
  • Lines are 2px, marks are thin, gridlines recede, and adjacent fills are separated by a 2px gap.

Graph · @ruckc/truss-graph

import { Graph, autoLayout, forceLayout, radialLayout, routeEdge } from '@ruckc/truss-graph';
<Graph bind:nodes bind:edges edgeType="avoid" minimap nodeTypes={{ decision }} {edgeLabel} />

Custom node components

Register a snippet per node.type. It receives { node, selected, dragging, update } and draws the whole node. Mark interactive regions with the nodrag class so dragging a slider or input inside a node doesn't drag the node.

Ports

Nodes get an input on one side and an output on the other by default. Declare ports: [{ id, side, kind, label }] for any number of named ports on any side; edges pick them with fromPort / toPort.

Edge routing

edgeTypeBehaviour
bezierA smooth curve leaving and entering each port perpendicular to its side.
straightA direct line.
stepOrthogonal segments with sharp corners.
smoothstepOrthogonal segments with rounded corners.
avoidOrthogonal routing that steers around other nodes (A* over a visibility grid, penalising bends), falling back to step if boxed in.

Set it for the whole graph with edgeType, or per edge with edge.type.

Explorer mode

Set editable={false} for a read-only viewer: arrow keys move between neighbouring nodes instead of moving them. Choose a layout (layered, force or radial; the pure forceLayout and radialLayout functions are exported too), give nodes a shape (rect, circle, diamond) and show a legend whose edge kinds toggle visibility. Through bind:this the graph exposes zoomTo(id), center(), highlight(id, 'up' | 'down' | 'both'), clearHighlight(), collapse(id) and expand(id). Large graphs (hundreds to a thousand nodes) cull off-screen nodes and edges and simplify nodes when zoomed far out.

<Graph bind:this={g} {nodes} {edges} editable={false} layout="force" legend minimap />
<Button onclick={() => g.highlight('orders', 'down')}>Downstream</Button>

Node callbacks

onnodeclick(node, event), onnodedblclick(node, event), onnodehover(node | null) and onnodecontextmenu(node, event) report interaction without changing it: selection, dragging, connecting and read-only traversal work as before. A click fires after the selection has updated; the click that ends a drag, and clicks on ports, collapse badges and controls inside custom nodes (inputs, buttons, links, .nodrag) are not node clicks. A double-click delivers two clicks and then onnodedblclick (the graph has no double-click action of its own). From the keyboard, Enter on a focused node reports a click and then toggles its collapse; call event.preventDefault() in the handler to keep the collapse from happening. ContextMenu or Shift+F10 reports onnodecontextmenu once, and the native menu is suppressed while the callback is set.

Colours

Give a node a category (an index from 0 into the chart categorical palette, 8 slots that wrap) or any CSS color. The colour wins over kind, and color wins over category. The palette is --trc-s1 to --trc-s8 when the charts stylesheet is loaded and built-in light and dark values otherwise, so the graph needs no charts CSS. With legend, each category in use gets a row named by the categories prop (['Table', 'View'], else "Category N"); legend={{ categories: false }} hides those rows. Nodes with a custom color are not listed.

Incremental layout

The pure layouts take pin, a list of node ids: autoLayout(nodes, edges, { pin }), forceLayout(...) and radialLayout(...) return pinned nodes exactly where they were and place every other node near the neighbours it is connected to (next column for layered, outward from its neighbour for radial, starting beside it and relaxing for force), without overlapping anything. They stay deterministic. On the component, g.layout({ pin }) does the same through the chosen layout and leaves the viewport alone; g.layout() still re-runs everything and fits. For "expand neighbours": add the nodes and edges, then call g.layout({ pin: previousIds }).

<Graph bind:this={g} bind:nodes bind:edges layout="force" legend {categories} onnodedblclick={expand} />
async function expand(n) {
  const pin = nodes.map((x) => x.id);
  nodes = [...nodes, ...fresh]; edges = [...edges, ...links];
  await tick();
  g.layout({ pin });
}

Overlays inside nodes

A HoverCard or Popover (fixed) in a node body is positioned against the page, not the zoomed canvas: the graph lifts it into an overlay layer outside the transformed canvas, keeps it at its natural size, and re-places it while you pan or zoom. Give such popovers an anchor. Pointer, wheel and key events inside the lifted card do not reach the graph.

Map · @ruckc/truss-ol

OpenLayers is a peer dependency, so you control its version, and it is loaded lazily in the browser.

npm install @ruckc/truss-ol ol
import { Map } from '@ruckc/truss-ol';
<Map {markers} {geojson} basemap="none" bind:selected bind:center bind:zoom height={420} />

Marker, line and polygon colours are read from the tokens at runtime and re-read when the theme changes. The none basemap draws a themed graticule and works offline; osm and custom XYZ tile URLs are dimmed and tinted in dark mode.

/docs/packages Truss UI · MIT