portrayal.dev

Docs · Using the kit

Put a device in your own page.

Portrayal compiles each device into ordinary files: an SVG per face, and JSON indexes beside them. Everything below runs here, live, and the code beside each result is the file that just ran it. This page documents @portrayal/kit 0.51.0.

Start here

A compiled device is one SVG per face (<device>.<config>.<view>.svg) plus devices.json, components.json and a <device>.configs.json and a <device>.source.json per device. The kit, @portrayal/kit, is plain ES modules with no build step; three.js is needed only for 3D. A face that several configurations draw identically is written once, so find a configuration's face with faceFile(index, config, view) from @portrayal/kit/dist rather than building its name; each device's default configuration is also at <device>.<view>.svg. The recipes below use the UfiSpace S9600-72XC.

Beside every face, under the same name, is <device>.<config>.<view>.elements.json: the face's elements as data, each with its path, class, part reference, box and connection points, so you can read a face's tree without parsing or laying out the SVG. It is read from the SVG beside it and adds nothing; its format is in Portrayal's format-stability.md. It comes with a build of library/dist/ (see Getting the files); portrayal.dev doesn't serve it.

The drawing is the API

No kit at all: fetch a face, insert it, and address its parts by data-path. A lamp is lit by a state class the drawing already styles; a click tells you which part it hit.

Color it from your data

A port is an element like any other, so your monitoring can paint it. Here the numbers are a table in the file; in a dashboard they are whatever your telemetry returns.

Read the device from the drawing

Each device publishes its source manifest once, as <device>.source.json: the device, its chassis in millimeters, its configurations. Every face names that file by its SHA-256 in <metadata>, so a drawing can be checked against the source it was drawn from. And devices.json lists everything in the library.

Seat an optic or a cable end

A cage is empty until you say what's in it. Name the part for the cage, and for an optic its two LC bores; the kit draws each part's own artwork into the face and refuses anything the cage doesn't accept.

In 3D

The same files, as a model you can turn over. The viewer takes the same paths and lamp states as the drawing (setStates; a state on a switched outlet reaches the lamp bound to it, as it does in 2D), the same text on parts (setFields), and exports GLB and USDZ (download). createViewer(el, {ears: true}) or setEars(true) adds the generic rack ears described below, and a GLB taken then carries them. It needs three.js; the page's import map says where that lives.

Rack ears

A published face has no rack ears: a device is drawn between its ear folds. @portrayal/kit/ears2d draws the generic L-bracket over the face you already hold, so a page can offer the ears as a choice: off by default in a device view, on in a rack. earPlan(configs, frontWidth) is the plan the 3D viewer builds from, and it is null where the device gets none: a front as wide as the rack (482.6 mm), whose ears are built into the face and drawn already, such as a blanking panel; a device that is not rack-mounted; or ears stated behind. The ear is silver (#c8cacc) unless the device states chassis.ears.color. drawEars(svg, plan, view) grows the viewBox to hold them and clearEars puts it back. The overlay is a picture, not a part: it has no data-path and takes no clicks, and an export of the live drawing carries it.

An outlet and its lamp

A switched PDU's outlet declares the states on and off, and the lamp that shows them is a part beside it, bound to the outlet with for:. Set a state on the outlet's path and it is shown on its lamp. @portrayal/kit/states has the one rule every place that applies a state uses: boundLamps(svg) maps each outlet's path to its lamps, and expandStates(map, bindings) turns a map keyed by outlets into one that reaches their lamps too (a lamp stated in the map itself keeps its own). A marks document, the Explorer's state chips and viewer.setStates all apply it, so 2D and 3D agree.

off can be lit: offIsSet(el, lamps) says whether an off chip sets state-off (an outlet, so its lamp shows the color its device declares for off, or a power outlet with no lamp, which is dimmed) or clears the element, as it does on a plain lamp. The module also reads a lamp's vocabulary (statesOfEl) and whether a state paints anything (paints).

A display is a window the drawing shows, and what it shows is a rack file's to say. @portrayal/kit/displays has the rules: a display of a part says its kind (pixel, character or segment), and contentRefusal(display, content) says why {text: [lines]}, {digits: "04"} or {image: dataUrl} does not fit it. One painter, paintDisplays(root, displays), draws the text, picture or digits for the 2D drawing and for the 3D texture alike (shell.setDisplays, viewer.setDisplays); loadDisplayImages reads a library picture first. A segment display's digits are the state- class of each cell.

Annotate and export

Describe what to mark, not how to draw it: a document of selectors, colors, lamp states and labels. A state on a switched outlet lights the lamp bound to it as well. The same document previews on the page, exports as an SVG you can drop into Word (toSvg), or as a PNG or GIF (toPng, toGif), and fits in a share URL (encode).

The Explorer shell

If you want the whole explorer rather than parts of it, mount the shell. It styles the page it lands in, so it runs here in a frame of its own. The Explorer is the full example, with 3D.

One load holds the stage at a time. When a newer loadDevice or loadStage overtakes one you awaited, yours writes nothing and rejects with an error that isSuperseded(err) recognizes: another load replaced it, which is not a failure. Annotate treats it that way.

A part that slides

A device may declare a part that slides, such as a DIN rail panel on slotted brackets (since 0.19.0). The compiled drawing says what moves: the root of every face carries data-adjustments, and each member node data-moves-with and data-moves-by. The FS DINRAIL2U and DINRAIL4U declare one, rail-setback, how far the rail panel stands behind the ears.

A position is a field. It is held at the path of the adjustment's carrier, under the adjustment's id, in the map every other field is in, so a fields= link carries it and the 3D viewer takes it with setFields. A value is a number in mm or the name of a stop; what is kept is the number, rounded to 0.1 mm. A value that is not a position is not applied, and setFields answers why in a sentence (it returns {refused: [...]} for every call since 0.19.0). An empty value puts the part back where the drawing was built.

The control (since 0.20.0). positionControl(shell, opts) in positions is the control the Explorer mounts above its tree: for each adjustment of the device on screen, a slider over the range in steps of 0.1 mm, a number box, a button for each named stop and a reset. It writes through shell.setFields and holds no rule of its own. A view that cannot show the motion names the views that do. The page says which stage is showing by emitting mode on the shell; in 3D each position is a scene to rebuild, so the value is written when the slider is let go.

import {positionControl} from '@portrayal/kit/positions';

// shell: the shell mounted above
positionControl(shell, {threeD: () => true});     // {el, refresh}; opts.before: where it goes
shell.emit('mode', '3d');                         // the page says which stage is showing

shell.setFields('rail-panel', {'rail-setback': 'rear'});   // a stop, or a number in mm
// ?fields=rail-panel~rail-setback~271.2
shell.setFields('rail-panel', {'rail-setback': '300'});
// {refused: [{path, key, value, reason: 'rail-setback on DINRAIL2U takes 51.2 to 271.2 mm. 300 is outside it.'}]}

A rack, by commands

The rack/ modules are the Rack Builder's rules without its page. They read no DOM and fetch nothing but the rack catalog (rack.json) and the cable types, so a rack is checked the same way in a browser, in node and by an agent. Each is imported on its own, as @portrayal/kit/rack/<module>.

createRackEditor({doc, chassisOf}) holds a rack document and takes every edit as a named command through apply: place, move, patch, remove, attach, detach, frame, rename, dcim, fit, field, the side.*, zerou.*, cable.* and bundle.* commands. A batch lands whole or is refused with the reason; each batch is one step for undo and redo, and on('change') hears every one. COMMANDS in rack/commands names each command's arguments as JSON Schema, which is what an agent reads. preview answers what a batch would do without doing it.

To read a rack back, describe(rack, ctx) is the whole rack as text (or one stretch of a long one, with {section, offset, limit}), and inspect(rack, id, ctx) is one device, cable, part beside the rack or bundle as data. selectCables, freeUs, fitsAt and catalog answer what a command needs to know first.

Beside the rack

A part that stands beside the rack and takes no rack unit, such as a vertical cable manager, is an entry of rack.zeroU, not an item: zerou.place {ref, at, ru} stands it at an upright (left or right on a two-post, left-front to right-rear on a four-post), and zerou.update and zerou.remove move and remove it. The gutter beside that upright then runs through it, so a routed length is measured through the duct. A zero-U PDU carries no lane: the lane beside it runs just outboard of it, and a cable is taken round it. A finger bracket narrower than the opening can stand on one rail: side.place and side.set.

Routes and ring marks

resolveRoute gives the waypoints a cable follows; routePath(rack, cable, ctx) is that route in millimeters, each D-ring expanded to where the cable enters and leaves it, and it is what routedLength, fill and the capacity checks measure. Each ring is decided once, there. ringMarks(rack, cable, ctx) hands those decisions to a drawing, one per waypoint, so the drawing passes through each ring the way it was measured. A mark's sense is along the rack's axes (x right as seen from the front, y up); a drawing whose axis runs the other way turns it with orientMarks, and a path drawn from its other end with reverseMarks:

import {resolveRoute, ringMarks, orientMarks, reverseMarks} from '@portrayal/kit/rack/route';
import {routed2d} from '@portrayal/kit/rack/route-path';

const marks = ringMarks(rack, cable, ctx);        // parallel to resolveRoute(...).waypoints
const front = orientMarks(marks, {y: -1});        // an SVG front elevation: y runs down
const rear = orientMarks(marks, {x: -1, y: -1});  // the rear, seen mirrored
const back = reverseMarks(front);                 // the same path, drawn from end b
const d = routed2d(points, 4, front);             // an SVG path through each ring, straight

The ctx comes from the drawings: guidesOf(itemId), the rings, ducts and pass-throughs on a part, and portX(end), a port's x in millimeters from the rack's center line; with chassisOf from rack/catalog, and, best, diameterOf from loadCableTypes(dist), each cable's own diameter. The recipe above uses a plain one, which routes by the gutters alone. A page that draws the plugs gives plugReachOf(end, cable) as well, and the cable types' typeOf and bendOf (below).

Build a new context when what it reads changes. Since 0.17.0 the kit keeps what it decides for a cable (the side of a route, where its turns are given room) per rack, context and cable, because every reader of a path asks for it again and the search is the costly part. A context changed in place can therefore give a path placed for what it read before. When a device moves, a cable's type changes, a port is swapped or a plug's reach is read, make a new context object and route with that; freeze it if you want a stray write to throw.

A ring holds a cord that only reaches into it. A route that would enter and leave a ring by one face is a hook, not drawn through the ring and reported by ringFindings. Since 0.12.0 there is one exception: when the ring stands right past the port next to it (its near face no further from that port than the ring's depth plus the cable's diameter, the diameter counted at most up to the depth), the cord reaches in to be held. That ring is a pass with held: true on the path's ring, it is counted, and it is not a finding. Its mark is a pass like any other, so a drawing needs nothing new for it.

The automatic route runs a patch whose two ends leave through one manager along that manager, port to port, through the rings between the two ports, and never out to a gutter and back; with no ring between, through the ring nearest the middle. Any other route takes the gutter chosen from both ends: the side both ports stand on, or, when they stand on opposite sides, the side whose path is the shorter. Routed lengths change in 0.12.0 wherever that applies.

Solid bodies

Every body in the rack is solid to a route (since 0.11.0). solidsOf(rack, ctx) in rack/solids places each one in rack millimeters: a box device is its envelope; a sheet part, such as a lacer panel, is the plates its rack.json entry derives (solids: its tray floor, webs and ears), with the pass-throughs that cut them; a zero-U PDU is its envelope. A cable crosses a body only through a ring, a duct's finger gap, or a pass-through its diameter fits. Where a straight leg would cross one, routePath adds detour points (at: 'detour', listed in path.detours): over the body's near edge, a tray's front edge first, else round its end, else by a side lane. They count in the length and are never stored, so a drawing that joins only the waypoints is shorter than what was measured, and goes through the tray; draw the detours too. Beside a zero-U PDU the lane runs outboard of it: place lanes with laneXAt(rack, lane, ru, chassisOf).

A leg the rules cannot clear is left as drawn and reported: bodyFindings(rack, ctx, nameOf) gives each as {kind: 'crosses-body', cable, item, part, between, at, text}, beside ringFindings. It warns and never refuses. inspect of a cable lists them as route.crosses, describe adds a Findings: line when given the route context, and cableScheduleRows(..., {bodies}) writes each sentence into its cable's notes:

import {routePath, bodyFindings, laneXAt} from '@portrayal/kit/rack/route';
import {solidsOf} from '@portrayal/kit/rack/solids';
import {loadCableTypes} from '@portrayal/kit/rack/cable-types';

const {diameterOf} = await loadCableTypes(dist);
const route = {chassisOf, guidesOf, portX, diameterOf};
const path = routePath(rack, cable, route);       // points include {at: 'detour'}
const bodies = solidsOf(rack, route);             // [{item, part, box: {x0, x1, y0, y1, z0, z1}}]
for (const f of bodyFindings(rack, route)) console.warn(f.text);

Reach, resting and solid rings

The plug's reach (since 0.13.0). A cable leaves the far end of its plug, not the port face, so each path starts and ends at a reach point (at: 'reach', with its end): the port's point moved straight out of its face by the plug's reach. The page gives the reach as ctx.plugReachOf(end, cable), in mm, read off the seated plug (the z of relief.js cablePoints, an optic's standing-out included); without it the kit takes PLUG_REACH by media (27.6 mm for LC fibre, 39.4 for copper, 64.8 for a DAC or an AOC). A drawing that starts its tube there and runs its first leg from there follows the measured leg, round whatever it has to clear.

Trays and resting (since 0.14.0). A tray is a pathway with a floor: traysOf(rack, ctx) in rack/solids places each one, and a route names it by its id as it names a ring. A cable through a ring on a tray lies on the ring's sill, its radius above the opening's lowest inside edge, at the side nearer the rail; along a tray it lies on the floor (at: 'tray'); and every leg nothing holds is a free span that hangs by its family's drape and lands on any surface below it (at: 'rest'). Give ctx.typeOf and ctx.bendOf from loadCableTypes, so a span hangs by its own type and no tighter than its installed bend radius. path.rests, and inspect's route.rests, list what a cable lies on: a ring's sill, a tray (its face and role) or a body a span came to rest on.

Rings are solid (since 0.15.0). Where a ring's contract says what of its loop is solid, solidsOf places its legs, bar, seat and hook as ring/<id>/<part> with the other solids. A cable passes a ring close by on purpose, so a ring's part is met by the cable's tube (the part grown by its radius), where any other body is met by the center line. Every pass through a ring whose opening is placed starts and ends at an approach point on the ring's run, clear of its band (at: 'approach'), so a detour or a leg from behind or below ends there and the cable enters through the opening. A drawing that keeps its tube out of the solids should take a ring's part grown by the tube's radius too.

import {routePath} from '@portrayal/kit/rack/route';
import {loadCableTypes} from '@portrayal/kit/rack/cable-types';

const {diameterOf, typeOf, bendOf} = await loadCableTypes(dist);
const route = {chassisOf, guidesOf, portX, portY, diameterOf, typeOf, bendOf,
               plugReachOf: (end, cable) => reachOfPlugSeatedAt(end)};  // mm, or null for the default
const path = routePath(rack, cable, route);
// path.points: a, reach, [lead | approach, entry, exit, approach | tray | rest | detour | lane]..., reach, b
// path.rests:  [{kind: 'ring', item, via} | {kind: 'tray', item, via, face, role} | {kind: 'body', item, part}]

The end allowance and room for the bends

The end allowance (since 0.16.0). A routed length counts, beside the path, what is inside the port at each end, by the cable's media. END_ALLOWANCE_BY_MEDIA is that table in meters an end (its name since 0.18.0), and since rack file version 4 it holds the part of the plug inside the port and nothing else: 0.0131 for LC fiber, 0.0095 for copper and a cable with no media, 0 for a DAC and 0.0524 for an AOC. endAllowance(cable) is that figure and routePath returns it as allowance. The maker's short tolerance and the 0.1 m an end of dressing the table and the allowance once held are named on their own now, shortTolerance and END_PLAY_M (below): a page that took the dressing as endAllowance less the table gets 0.

Room for the bends (since 0.17.0). The path is the cable, so its corners are its bends, and routePath places its points for the cable's own installed bend radius (ctx.bendOf(cable), else its media's). Where the route turns onto a ring's run the approach point stands the bend radius from the band; a cable whose port stands behind that point is led square to it through a lead point (at: 'lead', with the ring's item and via), and one whose first turn needs more leg than its plug gives runs straight on out of it to a lead point with its end. A detour stands two bend radii from each end (detour's room option), and a free span hangs only as deep as the corners beside it have room for. Draw a lead point as any other point of the path. Routed lengths grew in 0.17.0 on every routed cable that turns at a ring, goes round a body or hangs.

What the kit's rules find no room for is still routed, and is said. The path's bends lists each corner short of the radius, as {kind: 'tight-bend', cable, point, between, at, angle_deg, legs_mm, room_mm, need_mm, short_mm}, and bendFindings(rack, ctx, nameOf) gives them for the whole rack with a sentence, as bodyFindings does for crossings. It warns and never refuses. at is the corner in rack millimeters, so a page can mark it where it is. The measure is cornersOf(points, {share: 'need'}) in rack/route-path: the room a corner's legs leave it, a leg between two corners shared by what each turn uses of it. Since 0.21.0 inspect of a cable carries them as route.bends, describe counts them in its Findings: line, and cableScheduleRows takes them as bends for a cable's notes. Since 0.27.0 two turns on one short leg are one finding, with apart_mm and turns: [{point, at, angle_deg}, {point, at, angle_deg}]; its at is the middle of the leg between them, so a page that marks each corner marks turns[].at. Its sentence reads “turns twice within 22.8 mm” and is longer than a single bend's, so do not clip it at a fixed length. A finding for a lead put into a ring too close for it also carries advice (one sentence) and tooClose, and its text is the bend alone.

The ring is chosen by room (since 0.27.0). Of the rings on a lead's way to its gutter the first that the lead can turn into within its cable's bend radius is taken, and one too close is passed over; a patch along one manager takes the rings between its ports the same way. Where no ring leaves room the nearest stands and its finding says so. Routed lengths on a saved rack can move: a cable whose automatic route takes another ring is measured differently the next time a page measures it, and its stock size can change. An entered length and a route made by hand are not touched.

A span in front of other ports (since 0.21.0). With no horizontal manager a cable runs from its lane to its port across the faces between. routePath returns covers, each port such a span passes in front of, and coverFindings(rack, ctx, nameOf) gives one finding a cable, with the ports by device and the advice to add a horizontal cable manager. It warns and never refuses. A context that supplies portBoxesOf(itemId), every port of a device as [{path, view, x, y, w?, h?}] in the frame portX and portY use, has every port judged; without it only the ports that hold a cable are, and the sentence says so. Since 0.21.0 a cable is also laid from both ends and one lay kept, so a reader on the context (plugReachOf, trayFaceOf, bendOf, diameterOf) can be handed the same cable with a and b exchanged: key what it answers on the end, not on which of the two the cable calls a.

import {routePath, pathLength, endAllowance, END_ALLOWANCE_BY_MEDIA, bendFindings} from '@portrayal/kit/rack/route';
import {cornersOf} from '@portrayal/kit/rack/route-path';

const path = routePath(rack, cable, route);       // route: a context built for the rack as it is now
const length = pathLength(path);                  // {value, need, measured}: the stock size, the need, and the need plus the end play
path.allowance === endAllowance(cable);           // END_ALLOWANCE_BY_MEDIA[cable.media]: what is inside the port
for (const b of path.bends) console.warn(b.cable, b.at, b.room_mm, b.need_mm);
for (const f of bendFindings(rack, route, id => labelOf(id))) console.warn(f.text);
const corners = cornersOf(path.points, {share: 'need'});   // [{angle_deg, legs_mm, room_mm}]

Rack file version 4

The rack file is version 4. A version 1, 2 or 3 file opens as it was and is saved as version 4; a reader older than this refuses a version 4 file. Its schema is /schemas/v3/rack.schema.json. The reader keeps a key it does not know at every level of the file.

Half units. A position (ru on an item) is a whole unit or a half, counted bottom-up from 1: 12 is the bottom of U12 and 12.5 its middle. rack/fit owns what follows from one: unitsOf(chassis), a device's height in halves; spanOf(item, chassisOf), the half-open [lo, hi) it fills; itemBox(rack, item, chassisOf), {y0, y1, h, lo, hi} in mm above the bottom of the rails, the body centered in its span, always, which is where a drawing stands it; and spanText, the span in words ("the lower half of U7"). heightOf, the u of itemsWithU and freeUs answer in halves, and positionOf and uText in rack/model give "U12.5". span, the closed whole-unit pair, is removed. A device of a whole number of units is refused at a half position, in a sentence: its ears meet no hole there. holesIn(lo, hi) in rack/rails gives the rail holes an ear between two heights can use, for a page that draws ears.

The lengths of a cable, named (rack/route). The need is the path, what is inside the two ports and the maker's short tolerance (needOf, shortTolerance); the stock is the length bought; the spare is stock less need; the end play is the part of the spare that stays in the curves into the ports, END_PLAY_M (0.1 m) an end; and what is left is to store. spareOf(rack, cable, ctx) gives all of them in meters. pathLength answers need beside value and measured, and stockLength(need) still chooses a cord up to BREAK_TOL_M (10 mm) short of full end play. A cable keeps length.need where it kept measured.

Where the spare is (rack/slack). A cable takes slack, a list of where its spare is stored, in a tray it rests on or at an end (cable.slack, cable.slack.clear). slackOf resolves it against the route, storedText words it, slackFindings says what is wrong (spare-unplaced, spare-short, spare-over, spare-off-route), spareLine is one line for a rack, and spareExports(rack, ctx) is what the exports take: cableScheduleRows fills its spare and stored columns from {spare} and its last column, straps, from {straps: strapTexts(rack, ctx)}. Spare is recorded and listed, not drawn, and the kit places none itself.

The frame and what stands in it. A frame takes startU, the number of its first unit, and setback; an item takes setback, roll (90, 180 or 270 degrees clockwise as seen from its face) and heldBy. setbackOf(rack, item, chassisOf) is how far a faceplate stands behind its rail: the item's, else the device's default ear position, else the frame's, else flush. facePoint turns a point of a face as drawn into the rack, through face, turned and roll. frameNotices(rack, chassisOf) gives a sentence for each key the kit keeps and does not yet act on.

States and readings (rack/states). An item and a part beside the rack take states and readings (state.set, readings.set). statesFor(entry, facts) is the {path: state} map to hand a drawing, an outlet's state carried to its lamps; readingsFor is {source, at, badges}; and stateNotices(rack, ctx) names what is kept and not applied. A zero-U PDU hangs on brackets with zerou.hang, and hangNotices in rack/zero-u says what is wrong with one. A page calls the notice functions once it has loaded the parts lists; the editor cannot, since it holds none.

import {itemBox, spanOf, spanText, unitsOf, fits} from '@portrayal/kit/rack/fit';
import {holesIn} from '@portrayal/kit/rack/rails';
import {spareOf, END_PLAY_M} from '@portrayal/kit/rack/route';
import {spareExports} from '@portrayal/kit/rack/slack';

const upper = {...item, ru: 12.5};                 // a half-unit panel in the upper half of U12
spanOf(upper, chassisOf);                          // [12.5, 13)
const box = itemBox(rack, upper, chassisOf);       // {y0, y1, h, lo, hi} in mm above the bottom of the rails
holesIn(box.lo, box.hi);                           // the one rail hole that half holds
fits(rack, {ref: 'a-1u-switch', face: 'front', ru: 12.5}, chassisOf).reason;
// "... is 1U; its ears meet the rail holes only at a whole unit, and U12.5 is a half unit."

const s = spareOf(rack, cable, route);             // {need_m, stock_m, spare_m, end_play_m, to_store_m, pinned}
const spare = spareExports(rack, route);           // {byCable, line, bom}: hand it to the exports as {spare}

The rack modules

modulewhat it does
rack/catalogloadCatalog(dist): the rack catalog and the chassisOf(ref) lookup everything else takes; loadSlots(dist, refs): the bays, cages and parts fit and field check against.
rack/modelThe rack file: newDoc, parseDoc, serialize, and edits that return a new rack (withItem, withFrame, ...). Files are version 4 since 0.22.0; older ones are read as they were and saved as version 4. positionOf, uText, firstU, rollOf, holderOf.
rack/editorcreateRackEditor: a rack document edited by commands, with undo, redo and change events.
rack/commandsEvery edit as a named, validated command (COMMANDS), and apply for a batch.
rack/historyUndo and redo as snapshots, as the editor keeps them.
rack/queriesReading a rack: describe, inspect, selectCables, fitsAt, freeUs, catalog, freePorts, suggestMedia, looseEnds.
rack/railsThe rails: the rack unit (44.45 mm), the opening, and where a device sits between them.
rack/fitWhether a device fits at a unit, on a face and on one rail; whether a zero-U part fits beside the rack (fitsZeroU); how a shrink trims a rack.
rack/zero-uThe parts beside the rack: where each stands, as words (whereText) and as x (zeroUX), and each placeable entry (zeroUEntries).
rack/managersCable managers: the placement of one onto the device behind it, and moving one.
rack/cable-rulesWhich two ports may be joined, withCable, media and lengths. Since 0.30.0 a power cord, a DC feed or a ground wire can land (cableLandsOn: a part of class port, or a connector slot that accepts one; media power and ground; an end may state its plug). A page that offers power cabling supplies ctx.drawnAt first, or an outlet-to-outlet cord between two devices gets no warning. Since 0.26.0 a manager's stated count is compared only with the cables at least as thick as the kind it counts (capacity.media in rack.json; a catalogue built before it is compared with cables of 6 mm or more), and portLabel words a port.
rack/routeresolveRoute, routePath (with each plug's reach point, each ring's approach points, each lead point, a tray's stretch and each free span's rest, and rests, bends and allowance), trayOf, PLUG_REACH, endAllowance, END_ALLOWANCE_BY_MEDIA, routedLength, pathLength, ringMarks, orientMarks, reverseMarks, laneXAt, pathway fill, ringFindings, bodyFindings and bendFindings.
rack/solidsThe solid bodies a route may not pass through: solidsOf (since 0.15.0 with a ring's legs, bar, seat and hook, ring/<id>/<part>), legCrossings, detour (since 0.17.0 with room, far enough out for the bends at each end), CLEAR, the clearance a detour keeps, and traysOf, the trays a cable lies on.
rack/route-pathThe 2D path through the rings (routed2d) and the geometry under it (throughRings, and cornersOf, the room each corner of a path leaves a bend).
rack/cable-geometryThe 3D path (routePoints3d), cable and plug colors, and the lead and sag figures the drawings share.
rack/export-dataThe rack as rows: bomRows, cableScheduleRows (with a bundle column, and each body a route crosses in its cable's notes), the bundles as the exports read them (bundleExports, bundleNotes, strapBomRows, strapBomNotes), the device-import data, and the drawing sheet's title block (titleLines, which names the host it was drawn with as source).
rack/dcim-rulesWhat a NetBox or Nautobot import needs of a rack: cable types, cable and module import rows (a NetBox cable in a bundle names it first in its description), and the import kit's README (kitReadme; a host passes its own source, where its DCIM settings are as settingsAt, and the script's scriptUrl).
rack/slackWhere a cable's spare is stored: slackOf, storedText, slackFindings, spareLine, spareBomLine, and spareExports, which the exports take.
rack/displaysWhat a display in the rack shows: displaysOf (the displays of a placed device or part), displaysFor (the {path: content} map to hand a drawing), displayRefusal, displayNotices and displayList; display.set sets or clears one.
rack/statesWhat an entry is set to: statesFor (the map to hand a drawing), readingsFor and stateNotices.
rack/validatevalidate(schema, value), the small JSON Schema validator the commands use, and same.

Bundles

Cables that share part of their route can be combed into a bundle and held with hook-and-loop straps. bundle.create, bundle.add, bundle.peel, bundle.update and bundle.remove make and change them, each one undo step. Given the routing context ({ctx: {route}}), bundle.create works the trunk out from where its cables run together (deriveTrunk), refusing a fork, a loop or groups that share nothing, by name; a member's own route then follows the trunk (followTrunk).

bundleCheck is the size check: the bundle's diameter, sqrt(sum(d²) / 0.8), against the smaller of 63.5 mm and each pathway's opening. It warns and never refuses. Each cable's diameter comes from its type: loadCableTypes(dist) reads cable-types.json and returns diameterOf, bendOf (the installed minimum bend radius) and typeOf. straps places the straps along the trunk, kept off rings.

Straps are placed on the members' routed paths (since 0.28.0). A trunk used to be measured along the straight line from one waypoint to the next; the members do not run there, so a strap's {segment, t} named a place off the cables. straps and trunkLength now read the members' paths: t is the share of the segment's routed length, and each strap carries its own place, point (rack millimeters), dir (the way the trunk runs there) and on (the cable whose path the point is on). The lengths and the counts follow, so a saved rack's strap lines move. Where no path can be read, straps() and bundleExports say so with straight, or few where a minority of the riders have one, and the length and count are an estimate.

A one-waypoint trunk is a tie (since 0.51.0): cables that share one ring and nothing else are held by one strap round the pack at the ring, of kind: 'tie', its width from the members' diameters (19 mm, or 25 mm for a pack 25 mm across or more). trunkLength gives {metres: 0, tie: true}, and bundleExports gives length_m: null with tie and tie_at. With fewer than two cables riding there is no strap.

Given bendOf as well, the check also looks at the bend: at each corner of the trunk, and at each pathway that states a radius, against the largest bend radius among the members there, so one fiber makes a bundle as strict as fiber. A corner's room is worked out from its legs and marked estimated. A miss is a warning, never a refusal. A member with no radius is listed as unchecked in the check's notes, never passed. inspect gives a bundle's bend as {radius_mm, by, unchecked, points, violations}; it is null only without the routes. cornersOf and bendCheck are exported for a page that draws them (cornersOf lives in rack/route-path since 0.17.0, and rack/bundles still exports it). A cable outside a bundle, and a member's lead to its port, are not checked here: each cable's own path is, by routePath's bends and bendFindings.

For the exports, bundleExports(rack, ctx) in rack/export-data measures every bundle once: its members, length, strap count, size and bend. Pass it to the cable schedule as cableScheduleRows(rack, ends, items, routes, {bundles}), whose notes then carry a line per bundle (bundleNotes); its bundle column names each cable's bundle. strapBomRows(bundles) is the bill of materials' one hook-and-loop strap line, every bundle's straps summed, and strapBomNotes says which bundles' straps could not be counted.

Getting the files

Two things: the kit's modules, and the compiled files they read.

The kit is on npm as @portrayal/kit. three.js is a peer dependency, needed only for 3D:

npm install @portrayal/kit three@0.161

This page documents kit 0.51.0, which is not on npm yet: npm's latest is 0.2.0. The rack and bundle modules, and everything else added since 0.2.0, are in the unpublished version only, so npm install gets a kit without them, and the CDN map below returns 404s. Until 0.51.0 is published, use the files from a Portrayal checkout's kit/ folder, or this site's own vendored copy (vendor/*.js and vendor/rack/*.js), and point the import map at wherever you serve them.

With a bundler, import @portrayal/kit/viewer3d and the rest by name; the package's exports resolve them. Without one, the modules are plain ES modules with no build step, so an import map pointing at a CDN is enough. List each module you use, pinned to a version:

<script type="importmap">
{ "imports": {
    "@portrayal/kit/dist": "https://cdn.jsdelivr.net/npm/@portrayal/kit@0.51.0/dist.js",
    "@portrayal/kit/swap": "https://cdn.jsdelivr.net/npm/@portrayal/kit@0.51.0/swap.js",
    "@portrayal/kit/viewer3d": "https://cdn.jsdelivr.net/npm/@portrayal/kit@0.51.0/viewer3d.js",
    "@portrayal/kit/marks": "https://cdn.jsdelivr.net/npm/@portrayal/kit@0.51.0/marks.js",
    "@portrayal/kit/shell": "https://cdn.jsdelivr.net/npm/@portrayal/kit@0.51.0/shell.js",
    "@portrayal/kit/ears2d": "https://cdn.jsdelivr.net/npm/@portrayal/kit@0.51.0/ears2d.js",
    "@portrayal/kit/states": "https://cdn.jsdelivr.net/npm/@portrayal/kit@0.51.0/states.js",
    "@portrayal/kit/positions": "https://cdn.jsdelivr.net/npm/@portrayal/kit@0.51.0/positions.js",
    "@portrayal/kit/rack/catalog": "https://cdn.jsdelivr.net/npm/@portrayal/kit@0.51.0/rack/catalog.js",
    "@portrayal/kit/rack/fit": "https://cdn.jsdelivr.net/npm/@portrayal/kit@0.51.0/rack/fit.js",
    "@portrayal/kit/rack/rails": "https://cdn.jsdelivr.net/npm/@portrayal/kit@0.51.0/rack/rails.js",
    "@portrayal/kit/rack/slack": "https://cdn.jsdelivr.net/npm/@portrayal/kit@0.51.0/rack/slack.js",
    "@portrayal/kit/rack/model": "https://cdn.jsdelivr.net/npm/@portrayal/kit@0.51.0/rack/model.js",
    "@portrayal/kit/rack/editor": "https://cdn.jsdelivr.net/npm/@portrayal/kit@0.51.0/rack/editor.js",
    "@portrayal/kit/rack/queries": "https://cdn.jsdelivr.net/npm/@portrayal/kit@0.51.0/rack/queries.js",
    "@portrayal/kit/rack/route": "https://cdn.jsdelivr.net/npm/@portrayal/kit@0.51.0/rack/route.js",
    "@portrayal/kit/rack/route-path": "https://cdn.jsdelivr.net/npm/@portrayal/kit@0.51.0/rack/route-path.js",
    "@portrayal/kit/rack/solids": "https://cdn.jsdelivr.net/npm/@portrayal/kit@0.51.0/rack/solids.js",
    "@portrayal/kit/rack/bundles": "https://cdn.jsdelivr.net/npm/@portrayal/kit@0.51.0/rack/bundles.js",
    "@portrayal/kit/rack/export-data": "https://cdn.jsdelivr.net/npm/@portrayal/kit@0.51.0/rack/export-data.js",
    "@portrayal/kit/rack/cable-types": "https://cdn.jsdelivr.net/npm/@portrayal/kit@0.51.0/rack/cable-types.js",
    "three": "https://cdn.jsdelivr.net/npm/three@0.161.0/build/three.module.js",
    "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.161.0/examples/jsm/"
} }
</script>

The 2D recipes don't need three.js at all.

The compiled files are built, not downloaded. Clone Portrayal and run ./build.sh, which writes every device into library/dist/. The files are static, so serve that folder beside your page and set DIST in each recipe to where it is.

The compiled files are not on npm yet. Publishing them through a registry, split per device, is roc-ops/Portrayal #526 and #528; this section will change when that lands.