Canvas Dashboard displays are the paradigm for pixel art that happens to show live data. Process diagrams, P&IDs, equipment layouts, architectural overviews. Every element has an explicit Left / Top / Width / Height and you compose by placement, layering, and grouping.
Use Canvas when:
data cards, not diagrams. Rows and columns of gauges, trends, KPIs, tables. Every element lives in a Cell, and cells are arranged in a responsive grid that reflows on window resize.
Use Dashboard when:
Use Canvas (load Skill Display Construction — Canvas instead) when equipment positions relative to each other matter (pipe A connects to vessel B), you're building a P&ID, or you need animation dynamicsUse Dashboard (load Skill Display Construction — Dashboard instead) when the display is primarily data monitoring, grid-based cards, or there's no spatial relationship to preserve.
Prerequisite: load Skill Display Construction — Basics first. It covers theme-first thinking, write mechanics, the build loop, binding syntax, and spacing/typography tokens.
The worst Canvas displays are built element-first — you place a Rectangle, then another, then wonder why nothing aligns. The good ones are built zone-first.
| Canvas | Width × Height | When |
|---|---|---|
| Standard HD | 1366 × 728 | Default, works everywhere |
| Wide | 1600 × 900 | Modern control-room monitors |
| Full HD | 1920 × 1080 | Dedicated operator displays |
| 4K scaled | 3840 × 2160 | Rare — prefer 1920×1080 with StretchFill |
margin = 20
gap = 15
titleBar = 60 // top strip for display title + status
bottomPanel = 140 // for trend/alarm/summary
zoneHeight = Height - titleBar - bottomPanel - margin
zoneWidth = (Width - 2*margin - (N-1)*gap) / N
zone[i].Left = margin + i * (zoneWidth + gap)
zone[i].Top = titleBar
zone[i].Width = zoneWidth
zone[i].Height = zoneHeightFor a 1600×900 canvas with 3 zones: each zone is ~515 wide × 640 tall. For 4 zones: ~381 wide. For 2 zones: ~780 wide.
Canvas has no z-index property. The order of the Elements array IS the z-order — earlier elements render behind, later elements in front. Always place:
All Canvas-only. All theme-aware (Fill/FillTheme, Stroke/StrokeTheme, StrokeThickness).
| Shape | Required | Use when |
|---|---|---|
| Rectangle | — | Panels, status bars, tank bodies, bars, backgrounds. Has RadiusX/RadiusY for rounded corners. |
| Ellipse | — | Tank caps (top/bottom), status LEDs, sight glasses, pump bodies. Circle when Width=Height. |
| Polygon | Points ≥3 | Arrows, funnels, hoppers, diamonds. Auto-closes. Set Stretch: "None" to preserve exact point coords. |
| Polyline | Points ≥2 | Open curved lines. Doesn't close. Prefer Gridline for pipes. |
| Path | Data | Any curve, arc, complex shape. SVG mini-language (M L H V C Q A Z). |
| Gridline | Points ≥2 | Use for pipes and orthogonal connections. Constrained to horizontal/vertical segments. The P&ID convention. |
| Spline | Points ≥2 | Smooth curves through control points (Catmull-Rom). Rare — Path covers most cases. |
{
"Type": "Gridline",
"Left": 300, "Top": 200,
"Points": "0,0 100,0 100,60 200,60",
"StrokeTheme": "Water",
"StrokeThickness": 6,
"StrokeLineJoin": "Round",
"StrokeLineCap": "Round"
}Then a direction arrow (Polygon, no stretch):
{
"Type": "Polygon",
"Left": 490, "Top": 253,
"Width": 20, "Height": 14,
"Points": "0,0 20,7 0,14",
"Stretch": "None",
"FillTheme": "Water"
}Zigzag heat-exchange coils over a reactor body:
{
"Type": "Path",
"Left": 305, "Top": 220,
"Width": 130, "Height": 240,
"Data": "M0,0 Q65,20 130,0 M0,40 Q65,60 130,40 M0,80 Q65,100 130,80",
"StrokeTheme": "StateRed",
"StrokeThickness": 2,
"Fill": "#00000000",
"FillTheme": ""
}Note the stroked-but-unfilled pattern: Fill: "#00000000" + FillTheme: "" gives a transparent fill so the stroke shows without a filled shape underneath.
These are first-class shape primitives that write with just Type + geometry + colors. The platform auto-injects the underlying Path/Polygon geometry on save. Massive productivity win over composing from primitives.
| Type | Default geometry | Use for |
|---|---|---|
| Cylinder | Vertical cylinder with elliptical caps | Tanks, vessels, drums, silos |
| Gear | 8-tooth gear | Machinery icons, manual/auto toggles |
| Arrow | Right-pointing arrow | Flow direction, callouts (rotate via RotateDynamic for other directions) |
| Cloud | Puffy cloud outline | MQTT broker, cloud service, weather |
| Star | 5-pointed star | Favorites, highlights, quality marker |
| Hexagon | Regular hexagon | Node diagrams, honeycomb layouts |
| Pentagon | Regular pentagon | Rare — use for ANSI warning signs |
| Trapezoid | Isosceles trapezoid | Hoppers, funnels, cone-bottom tank sections |
{ "Type": "Cylinder", "Left": 300, "Top": 200, "Width": 80, "Height": 180, "FillTheme": "ElementBlue" }That's an entire vessel. No Points, no Data, no compositing.
When you read a display back after writing a Cylinder, you'll see a Path with auto-generated Data. This is expected — the shortcut is a write-time macro. For edits, either add new Cylinders (which normalize the same way) or edit the Path directly.
list_elements() is the authoritative runtime catalog. Any shape entry not returned by list_elements() in the current release should be treated as non-existent. For shapes beyond the standard set (e.g., triangle, octagon, custom forms), compose from primitives: Polygon with explicit Points handles most custom 2D shapes, and Path handles curves.
| Container | Children | Dynamics apply to | Use for |
|---|---|---|---|
| ShapeGroup | Shapes only | ALL children uniformly | "The whole vessel turns alarm red when Running=0" |
| SvgGroup | Auto-parsed from inline SVG string | ALL children (normalized to ShapeGroup on write) | "I have an SVG, I want dynamics per element" |
| Group | Any element type | Each child has independent dynamics | "Interactive panel with chart+buttons that moves as a unit" |
The killer feature: a FillColorDynamic on the ShapeGroup changes the fill of ALL children at once. Build a vessel from primitives, and the entire vessel can turn red on alarm:
{
"Type": "ShapeGroup",
"Left": 300, "Top": 200,
"Width": 180, "Height": 220,
"Stretch": "None",
"Children": [
{ "Type": "Ellipse", "Left": 40, "Top": 0, "Width": 100, "Height": 20 },
{ "Type": "Rectangle", "Left": 40, "Top": 10, "Width": 100, "Height": 180 },
{ "Type": "Ellipse", "Left": 40, "Top": 180, "Width": 100, "Height": 20 }
],
"FillTheme": "ElementBlue",
"Dynamics": [
{
"Type": "FillColorDynamic",
"LinkedValue": "@Tag.Reactor/Alarm",
"ChangeColorItems": [
{ "Type": "ChangeColorItem", "ChangeLimit": 0, "LimitColor": "#FF1E3A8A" },
{ "Type": "ChangeColorItem", "ChangeLimit": 1, "LimitColor": "#FFEF4444" }
]
}
]
}Any inline SVG string becomes a ShapeGroup with native WPF shapes:
{
"Type": "SvgGroup",
"Left": 224, "Top": 240,
"Width": 180, "Height": 220,
"SvgContent": "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 180 220'><ellipse cx='90' cy='14' rx='60' ry='10' fill='#95a5a6'/><rect x='30' y='14' width='120' height='180' fill='#3498db'/><ellipse cx='90' cy='194' rx='60' ry='10' fill='#95a5a6'/></svg>",
"Name": "ReactorVessel"
}Supported SVG elements: rect, circle, ellipse, line, path, polyline, polygon, g.
Supported attributes: fill, stroke, stroke-width, transform, style, class, opacity, rx, ry.
Supported transforms: translate(), scale(), rotate(), matrix().
Avoid: gradients, filters, clip-paths, text, embedded images.
Trade-off: SvgContent hex colors are opaque to the theme system. Fine for process-meaning colors (heat red, water blue), wrong for UI chrome. If you need theme-reactive composed equipment, use ShapeGroup directly.
Use Group when children need INDEPENDENT dynamics:
{
"Type": "Group",
"Left": 100, "Top": 100,
"Width": 400, "Height": 280,
"Children": [
{ "Type": "Rectangle", "Left": 0, "Top": 0, "Width": 400, "Height": 280, "FillTheme": "PanelBackground" },
{ "Type": "TextBlock", "Left": 16, "Top": 16, "Width": 360, "Height": 24, "LinkedValue": "Reactor Control" },
{ "Type": "Button", "Left": 16, "Top": 220, "Width": 120, "Height": 40, "LabelLink": "Start",
"Dynamics": [{ "Type": "ActionDynamic", "MouseLeftButtonDown": { "Type": "DynamicActionInfo", "ActionType": "SetValue", "ObjectLink": "@Tag.Reactor/Running", "ObjectValueLink": 1 }}] }
]
}The archetype: a cylindrical vessel with a heating jacket that changes color when the heater is running.
{ "Type": "Ellipse", "Left": 300, "Top": 180, "Width": 96, "Height": 24, "FillTheme": "ElementGray" },
{ "Type": "Rectangle", "Left": 328, "Top": 150, "Width": 40, "Height": 36, "FillTheme": "ElementGray" },
{ "Type": "Cylinder", "Left": 276, "Top": 200, "Width": 144, "Height": 300, "FillTheme": "ElementBlue" },
{ "Type": "Rectangle", "Left": 256, "Top": 280, "Width": 22, "Height": 180, "FillTheme": "OffFill",
"Dynamics": [
{ "Type": "FillColorDynamic", "LinkedValue": "@Tag.Reactor/Heater/Running",
"ChangeColorItems": [
{ "Type": "ChangeColorItem", "ChangeLimit": 0, "LimitColor": "#FF7F1D1D" },
{ "Type": "ChangeColorItem", "ChangeLimit": 1, "LimitColor": "#FFEF4444" }
] }
] },
{ "Type": "Rectangle", "Left": 418, "Top": 280, "Width": 22, "Height": 180, "FillTheme": "OffFill",
"Dynamics": [ /* same FillColorDynamic */ ] }Elements top to bottom: drive cap (Ellipse), motor housing (Rectangle), vessel body (Cylinder with ElementBlue), left jacket rail (Rectangle with heater-gated FillColorDynamic), right jacket rail (same).
For an impeller shaft: add a thin vertical Rectangle, apply RotateDynamic gated by Running:
{ "Type": "Rectangle", "Left": 344, "Top": 230, "Width": 8, "Height": 240, "FillTheme": "DefaultStroke",
"Dynamics": [
{ "Type": "RotateDynamic", "LinkedValue": "30", "IsRpm": true, "OnOffLink": "@Tag.Reactor/Agitator/Running" }
] }LinkedValue: "30" and IsRpm: true means "rotate at 30 rpm." OnOffLink gates the rotation — the shaft only spins when Running=1.
{
"Type": "Symbol",
"SymbolName": "Wizard/PUMP",
"Left": 500, "Top": 300,
"Width": 80, "Height": 80,
"SymbolLabels": [
{ "Type": "SymbolLabel", "Key": "State", "LabelName": "State", "LabelValue": "@Tag.Pump1/Running", "FieldType": "Expression" },
{ "Type": "SymbolLabel", "Key": "RPM", "LabelName": "RPM", "LabelValue": "@Tag.Pump1/Speed", "FieldType": "Expression" }
]
}The symbol itself knows how to change its fill based on State — no extra dynamics needed.
To add a click-to-open-detail action, add the dynamic directly on the Symbol:
"Dynamics": [
{ "Type": "ActionDynamic",
"MouseLeftButtonDown": { "Type": "DynamicActionInfo", "ActionType": "OpenDisplay", "ObjectLink": "PumpDetail" } }
]{ "Type": "Gridline", "Left": 200, "Top": 400, "Width": 300, "Height": 60,
"Points": "0,30 100,30 100,0 200,0 200,60 300,60",
"StrokeTheme": "Water", "StrokeThickness": 6,
"StrokeLineJoin": "Round", "StrokeLineCap": "Round" },
{ "Type": "Polygon", "Left": 490, "Top": 53, "Width": 20, "Height": 14,
"Points": "0,0 20,7 0,14", "Stretch": "None",
"FillTheme": "Water",
"Dynamics": [
{ "Type": "VisibilityDynamic", "LinkedValue": "@Tag.Pipe1/FlowRate" }
] }The arrow only appears when FlowRate is non-zero — a simple, strong visual cue.
For multi-colored pipe based on flow threshold:
"Dynamics": [
{ "Type": "LineColorDynamic", "LinkedValue": "@Tag.Pipe1/FlowRate",
"ChangeColorItems": [
{ "Type": "ChangeColorItem", "ChangeLimit": 0, "LimitColor": "#FF64748B" },
{ "Type": "ChangeColorItem", "ChangeLimit": 10, "LimitColor": "#FF38BDF8" },
{ "Type": "ChangeColorItem", "ChangeLimit": 50, "LimitColor": "#FF0369A1" }
] }
]Uses Recipe 1 (vessel) + Path overlay for coils:
{ /* Recipe 1 elements for vessel body with jacket */ },
{ "Type": "Path", "Left": 305, "Top": 220, "Width": 130, "Height": 240,
"Data": "M0,0 Q65,20 130,0 M0,40 Q65,60 130,40 M0,80 Q65,100 130,80 M0,120 Q65,140 130,120 M0,160 Q65,180 130,160 M0,200 Q65,220 130,200",
"StrokeTheme": "StateRed",
"StrokeThickness": 2,
"Fill": "#00000000", "FillTheme": "",
"Dynamics": [
{ "Type": "VisibilityDynamic", "LinkedValue": "@Tag.Reactor/Heater/Running" }
] }The coils are only visible when the heater is active — a subtle but unmistakable visual cue for operators.
Call list_dynamics() for the full list. Here are the 14 types grouped by what they do:
| Dynamic | What it does | Use for |
|---|---|---|
ActionDynamic | Run an action on mouse event | Navigation, tag writes, scripts, toggles |
CodeBehindDynamic | Run code on display lifecycle events | Init, cleanup, periodic updates |
HyperlinkDynamic | Open external URL on click | Documentation links, external dashboards |
| Dynamic | What it does | Use for |
|---|---|---|
FillColorDynamic | Change element Fill based on thresholds | Status indicators, process meaning |
LineColorDynamic | Change element Stroke based on thresholds | Pipe color-by-flow, boundary-alarm borders |
TextColorDynamic | Change text Foreground based on thresholds | Alert text, highlighted values |
| Dynamic | What it does | Use for |
|---|---|---|
RotateDynamic | Rotate element by angle or rpm | Pointers, impeller shafts, motors, compass needles |
ScaleDynamic | Scale element by tag value | Growth/shrink animations, level indicators |
MoveDragDynamic | Move element by tag value OR let user drag | Sliding valves, operator drag-to-set |
SkewDynamic | Skew element | Perspective effects, rare |
| Dynamic | What it does | Use for |
|---|---|---|
BargraphDynamic | Fill a Rectangle proportionally based on value | Tank level fill, progress fills, power meter strips |
| Dynamic | What it does | Use for |
|---|---|---|
VisibilityDynamic | Show/hide based on expression | Conditional panels, state-dependent overlays, coils-only-when-heating |
SecurityDynamic | Hide/disable based on user permission | Admin-only controls, role-based HMI |
ShineDynamic | Mouse-over highlight / glow effect | Hover feedback on clickable shapes |
status_indicator (shape that changes color on running/stopped):
{
"Type": "Ellipse",
"Left": 100, "Top": 100, "Width": 24, "Height": 24,
"Dynamics": [
{ "Type": "FillColorDynamic", "LinkedValue": "@Tag.Equipment/Running",
"ChangeColorItems": [
{ "Type": "ChangeColorItem", "ChangeLimit": 0, "LimitColor": "#FF808080" },
{ "Type": "ChangeColorItem", "ChangeLimit": 1, "LimitColor": "#FF34D399" }
] }
]
}toggle_button (click toggles a boolean, color reflects state):
{
"Type": "Rectangle",
"Left": 100, "Top": 200, "Width": 120, "Height": 40,
"Dynamics": [
{ "Type": "ActionDynamic",
"MouseLeftButtonDown": { "Type": "DynamicActionInfo", "ActionType": "ToggleValue", "ObjectLink": "@Tag.Motor/Start" } },
{ "Type": "FillColorDynamic", "LinkedValue": "@Tag.Motor/Start",
"ChangeColorItems": [
{ "Type": "ChangeColorItem", "ChangeLimit": 0, "LimitColor": "#FFEF4444" },
{ "Type": "ChangeColorItem", "ChangeLimit": 1, "LimitColor": "#FF34D399" }
] },
{ "Type": "ShineDynamic" }
]
}animated_motor (continuous rotation gated by boolean):
{
"Type": "Symbol",
"SymbolName": "Wizard/MOTOR",
"Left": 400, "Top": 300, "Width": 80, "Height": 80,
"Dynamics": [
{ "Type": "RotateDynamic", "LinkedValue": "30", "IsRpm": true, "OnOffLink": "@Tag.Motor/Running" }
]
}level_bar (tank-fill effect):
{
"Type": "Rectangle",
"Left": 200, "Top": 200, "Width": 120, "Height": 240,
"Dynamics": [
{ "Type": "BargraphDynamic", "LinkedValue": "@Tag.Tank/Level",
"MinValueLink": "0", "MaxValueLink": "100",
"BarColor": "#FF38BDF8",
"Orientation": "VerticalUp" }
]
}| ActionType | Extra fields | What it does |
|---|---|---|
OpenDisplay | ObjectLink: "DisplayName" | Navigate to another display |
ToggleValue | ObjectLink: "@Tag.X" | Flip a boolean tag |
SetValue | ObjectLink: "@Tag.X", ObjectValueLink: value | Write a specific value |
RunScript | ActionScript: "MethodName" | Run a CodeBehind method |
Mouse events beyond MouseLeftButtonDown: MouseRightButtonDown, MouseDoubleClick, MouseEnter, MouseLeave. Each is a top-level key on the ActionDynamic.
Dynamics array. Never as direct properties on the element.ChangeColorItems is a flat array. Not {"Type": "ColorChangeList", "Children": [...]}.Dynamics array.Page-to-page navigation belongs in the Header display, not on content pages. Content pages only get in-page actions (start/stop, open popup, acknowledge).
get_objects('DisplaysLayouts', names=['Startup'], detail='full').get_objects('DisplaysList', names=['Header'], detail='full').{
"Type": "TextBlock",
"Left": 48, "Top": 32, "Width": 400, "Height": 20,
"LinkedValue": "← Process Area Overview",
"ForegroundTheme": "AccentBrush",
"Dynamics": [
{ "Type": "ActionDynamic",
"MouseLeftButtonDown": { "Type": "DynamicActionInfo", "ActionType": "OpenDisplay", "ObjectLink": "ProcessAreaOverview" } }
]
}Path or Polygon on disk. Auto-geometry expanded.theme: prefix: {"FillTheme": "ElementBlue"} → {"Fill": "theme:ElementBlue"}.Stretch: "Fill" default silently dropped on Polygon/Polyline/Path/Spline. If you want Stretch: "None", it IS stored.#FFFAFAFA baked in. Explicitly set Background: "theme:PageBackground".Points.Background set explicitly (not #FFFAFAFA default)FillTheme: "PanelBackground" — FIRST in ElementsDynamics array (never as direct properties)ChangeColorItems is a flat array (no ColorChangeList wrapper)@Label. in element bindings (only @Tag.)list_elements() before useget_state after write shows errorList emptyRectangle - backgrounds, bars, pipes (when rectangular)
Ellipse - caps, LEDs, pump bodies
Polygon - arrows, funnels, custom closed shapes
Gridline - pipes (always prefer over Polyline)
Path - curves, coils, complex shapes
Cylinder - vessels, tanks (first-class shortcut)
TextBlock - all text (labels, values, composite)
Symbol - Wizard/Library/Solution symbols
ShapeGroup - composed equipment with unified dynamics
RadialGauge - temperature, pressure, any circular gauge...
The whole display is defined by:
Columns and Rows arrays describing the grid track sizesRow, Col, optional RowSpan/ColSpan, a Cell.HeaderLink for the card title, and Content (the element that fills the cell)<ac:structured-macro ac:name="code"> <ac:parameter ac:name="language">json</ac:parameter> ac:plain-text-body{ "Name": "OperationsOverview", "PanelType": "Dashboard", "DashboardDisplay": { "Columns": ["*", "*", "*"], "Rows": ["Auto", "*", "*"], "Cells": [ { "Row": 0, "Col": 0, "ColSpan": 3, "Cell.HeaderLink": "Plant Overview", "Content": { /* header card */ } }, { "Row": 1, "Col": 0, "Cell.HeaderLink": "Production Rate", "Content": { /* gauge */ } }, { "Row": 1, "Col": 1, "Cell.HeaderLink": "Active Alarms", "Content": { /* alarm viewer */ } }, { "Row": 1, "Col": 2, "Cell.HeaderLink": "Shift Output", "Content": { /* KPI */ } } ] } }</ac:plain-text-body> </ac:structured-macro>
The Columns and Rows arrays define track sizes. Each entry can be:
"*" — equal share of remaining space (like CSS 1fr)"2*" — twice the share (like CSS 2fr)"Auto" — size to content"240" — fixed pixel width"*,min=200" — fractional with a minimum in pixelsStandard patterns:
| Layout | Columns | Rows |
|---|---|---|
| 3 equal columns, 2 rows | ["*","*","*"] | ["*","*"] |
| Header + 2 equal rows | ["*","*","*"] | ["Auto","*","*"] |
| Sidebar + main | ["240","*"] | ["*"] |
| Header + sidebar + main | ["240","*"] | ["Auto","*"] |
| 4 KPIs across top, trend below | ["*","*","*","*"] | ["Auto","*"] |
| Operator wall (4×3) | ["*","*","*","*"] | ["*","*","*"] |
In Canvas you place a gauge at Left: 472, Top: 320. In Dashboard you place a gauge at Row: 1, Col: 2. The engine handles pixel positioning, cell padding, and reflow.
This is a completely different mental model. If you find yourself calculating Left/Top/Width/Height for Dashboard elements, stop — you're building a Canvas display by accident.
Setting a row or column to "*" gives that track the space; it does not force the cell's content to fill it. A fixed-size gauge in a "*" row will sit at its native size inside a large cell, not stretch. For content that should fill the cell, use stretch-friendly controls (TrendChart, BarChart, AlarmViewer, DataGrid). Gauges are fixed size; place them in cells sized to match.
Not every element type works inside a Dashboard cell. Call list_elements('Dashboard') for the authoritative compatibility list in the current release. Rule of thumb:
Dynamics work in Dashboard for visual controls (FillColorDynamic on a TextBlock, VisibilityDynamic on a chart) but the animation dynamics (Rotate, Scale, MoveDrag, Skew, Bargraph) are Canvas-only — they're silently ignored in Dashboard cells.
Six data cards in a 3-column × 2-row grid:
json { "Name": "PlantKPIs", "PanelType": "Dashboard", "DashboardDisplay": { "Columns": ["*", "*", "*"], "Rows": ["*", "*"], "Cells": [ { "Row": 0, "Col": 0, "Cell.HeaderLink": "Production Rate", "Content": { "Type": "CenterValue", "LinkedValue": "@Tag.Plant/ProductionRate", "CenterTextFormat": "N1", "AccentTextLink": "units/hr" } }, { "Row": 0, "Col": 1, "Cell.HeaderLink": "Active Alarms", "Content": { "Type": "CenterValue", "LinkedValue": "@Tag.Plant/AlarmCount", "CenterTextFormat": "N0", "AccentTextLink": "alarms" } }, { "Row": 0, "Col": 2, "Cell.HeaderLink": "Operator on Duty", "Content": { "Type": "TextBlock", "LinkedValue": "{@Tag.Shift/CurrentOperator}", "FontSize": 22, "FontWeight": "Bold" } }, { "Row": 1, "Col": 0, "Cell.HeaderLink": "Temp Trend", "Content": { "Type": "TrendChart", "Duration": "5m", "Pens": [{"Type":"TrendPen","LinkedValue":"@Tag.Plant/AvgTemp","Stroke":"#FFEF4444"}] } }, { "Row": 1, "Col": 1, "Cell.HeaderLink": "Pressure Trend", "Content": { "Type": "TrendChart", "Duration": "5m", "Pens": [{"Type":"TrendPen","LinkedValue":"@Tag.Plant/AvgPressure","Stroke":"#FF38BDF8"}] } }, { "Row": 1, "Col": 2, "Cell.HeaderLink": "Shift Output", "Content": { "Type": "BarChart", "LinkedValue": "@Tag.Shift/OutputByHour" } } ] } }
Twelve cells. Full-screen control-room overview with one card per reactor / line / zone:
json "Columns": ["*","*","*","*"], "Rows": ["*","*","*"]
Asset tree on the left, detail panel on the right:
json { "DashboardDisplay": { "Columns": ["240", "*"], "Rows": ["Auto", "*"], "Cells": [ { "Row": 0, "Col": 0, "ColSpan": 2, "Cell.HeaderLink": "Production Area A", "Content": { "Type": "TextBlock", "LinkedValue": "{@Tag.Site/Name} — {@Now}" } }, { "Row": 1, "Col": 0, "Cell.HeaderLink": "Equipment", "Content": { "Type": "AssetsTree" } }, { "Row": 1, "Col": 1, "Cell.HeaderLink": "{@Client.Context.AssetName}", "Content": { "Type": "ChildDisplay", "DisplayLink": "EquipmentDetail" } } ] } }
The AssetsTree has all its bindings pre-wired to @Client.Context.* by default — drop it in and navigation "just works."
json { "Row": 0, "Col": 0, "ColSpan": 2, ... } // cell spans columns 0 and 1 { "Row": 1, "Col": 0, "RowSpan": 2, ... } // cell spans rows 1 and 2
Use ColSpan on a header card to stretch it across all grid columns. Use RowSpan when you want a tall element (AlarmViewer, AssetsTree) next to shorter cards.
| Cell purpose | Best control | Why |
|---|---|---|
| Display title / section header | TextBlock with composite LinkedValue | "{@Tag.Site/Name} — {@Now}" in one element |
| Single-value KPI (numeric) | CenterValue | Pre-styled big-number-plus-unit tile |
| Single-value KPI (string) | TextBlock with FontSize 24+ | Simpler than CenterValue for strings |
| Time-series trend | TrendChart | The workhorse |
| Tag value dial | RadialGauge | Gauge with threshold bands |
| Tag value bar (linear) | LinearGauge | For bar-style KPIs with setpoint pointer |
| Cumulative count / meter | DigitalGauge or DigitalMeter | Specialty numeric readouts |
| Categorical comparison | BarChart | e.g., output by shift |
| Current alarms | AlarmViewer | Pre-wired to Client.AlarmPage context |
| Asset hierarchy navigation | AssetsTree | Pre-wired to Client.Context |
| Dropdown selector | ComboBox (DataTable-backed for FK) | Zero-script FK lookup |
| Data table / list | DataGrid | The list in list→detail |
| Document viewer (SOP, work order) | PdfViewer | URL-bound inline PDF |
| Fleet/plant map | MapsOSM | Lat/long-positioned markers |
| Embedded another display | ChildDisplay | Reusable detail panels |
| Multiple panels user switches | TabControl | Manual click-to-switch |
| Slideshow of status boards | Carousel | Auto-cycle with AutoCycleLink |
| Collapsible panel | Expander | Click to expand advanced settings |
| Card-per-item layout | FlowPanel | N items from a data source, one template |
Cell.HeaderLink patternEvery cell should have a Cell.HeaderLink. The value is either:
"Production Rate""@Tag.CurrentAsset/Name""Reactor R-101: {@Tag.R101/Status}"The header renders as a small title strip at the top of the card, in the theme's card-header style. Leaving it empty gives you a headerless card — useful for the header / status row banding the top of the display, but unusual elsewhere.
Most dashboard cells end up being trend charts. The canonical setup:
json { "Type": "TrendChart", "Duration": "5m", "YMinValue": 0, "YMaxValue": 100, "YLabels": 5, "XGridLines": 6, "YGridLines": 5, "LegendPlacement": "BottomPanel", "VerticalCursor": true, "BackgroundTheme": "ControlBackground", "ForegroundTheme": "TextForeground", "BorderBrushTheme": "DefaultBorder", "Pens": [ { "Type": "TrendPen", "LinkedValue": "@Tag.Reactor/Temperature_C", "PenLabel": "Temperature", "Stroke": "#FFEF4444", "StrokeThickness": 2 }, { "Type": "TrendPen", "LinkedValue": "@Tag.Reactor/Setpoint", "PenLabel": "Setpoint", "Stroke": "#FF34D399", "StrokeThickness": 1 } ] }
"Pens": [ {...}, {...} ] ← prefer this for readability"Pens": { "Type": "TrendPenList", "Children": [ {...} ] } ← older form, also accepted| Property | Required | Notes |
|---|---|---|
LinkedValue | ? | Tag binding: @Tag.Reactor/Temperature_C |
PenLabel | — | Legend text |
Stroke | — | Line color hex. Not theme-aware — hex only. |
StrokeThickness | — | Default 1; use 2 for emphasis, 3 for setpoint/limit |
YMin / YMax | — | Per-pen Y scale (overrides chart scale) |
YAxisPosition | — | "Left" (default) or "Right" for dual-axis plots |
Auto | — | true for auto-scaling that pen |
Visible | — | true/false |
| Duration | When |
|---|---|
"1m" | Demos — populates visibly in ~60s |
"5m" | Default operator view — recent activity |
"15m" | Short-term process monitoring |
"1h" / "4h" | Shift-length trends |
"24h" / "7d" | Production reports, historical review |
"BottomPanel" (default), "RightPanel", "TopPanel", "None". For narrow cells use "RightPanel". For wide cells "BottomPanel".
Call list_elements('Charts') for the authoritative chart catalog in the current release. TrendChart is the right default for time-series data. For X-vs-Y scatter plots or correlation displays, verify the current element set before committing — the available chart types and their schemas evolve between releases.
Use a UserType-typed Client tag as the "selected row" carrier. The DataGrid pushes selected-row values into its fields, detail controls read from its fields via @Tag.Selected<X>.Field. Zero code-behind.
@Tag.SelectedReactor with UserType: "ReactorRow".SelectedValuesLink to "@Tag.SelectedReactor". The DataGrid automatically pushes values from the currently-selected row into each matching field of the UDT.@Tag.SelectedReactor.Name, @Tag.SelectedReactor.Temperature, etc. They update automatically as the operator clicks rows.json { "Type": "DataGrid", "ItemsSource": "@Dataset.Query.ActiveReactors", "SelectedValuesLink": "@Tag.SelectedReactor", "Columns": { "Type": "GridColumnList", "Children": [ { "Type": "GridColumn", "Title": "Reactor", "FieldName": "Name", "Width": 120 }, { "Type": "GridColumn", "Title": "Temp (°C)", "FieldName": "Temperature", "Width": 100 }, { "Type": "GridColumn", "Title": "Pressure", "FieldName": "Pressure", "Width": 100 }, { "Type": "GridColumn", "Title": "Status", "FieldName": "Status", "Width": 100 } ] }, "BackgroundTheme": "ControlBackground", "ForegroundTheme": "TextForeground", "BorderBrushTheme": "DefaultBorder" }
json { "Row": 1, "Col": 1, "Cell.HeaderLink": "Temperature", "Content": { "Type": "CenterValue", "LinkedValue": "@Tag.SelectedReactor.Temperature", "CenterTextFormat": "N1", "AccentTextLink": "°C" } }, <p>{ "Row": 1, "Col": 2, "Cell.HeaderLink": "Pressure", "Content": { "Type": "CenterValue", "LinkedValue": "@Tag.SelectedReactor.Pressure", "CenterTextFormat": "N1", "AccentTextLink": "bar" } },</p> <p>{ "Row": 2, "Col": 1, "ColSpan": 2, "Cell.HeaderLink": "Status", "Content": { "Type": "TextBlock", "LinkedValue": "Status: {@Tag.SelectedReactor.Status}", "FontSize": 16 } }]]></ac:plain-text-body> </ac:structured-macro></p> <p>When the operator clicks a row in the DataGrid, all three detail cells update simultaneously. No script, no event handler.</p> <h3>Requirements</h3> <ul> <li>The UDT's member names must match the DataGrid column <code>FieldName</code>s (case-sensitive).</li> <li>The Client tag must be typed by that UDT.</li> <li>The dataset query must return rows whose column names match the UDT members.</li> </ul> <h2>Section 6 — ComboBox zero-script FK-lookup</h2> <ac:structured-macro ac:name="code"> <ac:parameter ac:name="language">json</ac:parameter> <ac:plain-text-body><![CDATA[{ "Type": "ComboBox", "ItemsSourceType": "DataTable", "ItemsSourceLink": "@Dataset.Query.OperatorsList", "DisplayMember": "FullName", "SelectedValuePath": "OperatorID", "SelectedValueLink": "@Tag.Shift/CurrentOperatorId", "Foreground": "theme:TextForeground", "Background": "theme:ControlBackground", "BorderBrush": "theme:DefaultBorder" }
When the operator picks "Maria Costa" from the dropdown, the OperatorID (say 42) lands in @Tag.Shift/CurrentOperatorId automatically. Other displays binding to that tag update immediately.
Use this for: operator selection, product changeover, reactor mode select, batch selection — anywhere the user picks one row from a database-backed list.
"DataTable" — the pattern above, dataset query as source"Array" — bound to an array tag"StringTag" — comma-separated string (legacy, avoid)"Text" — hardcoded comma-separated inline optionsThe AlarmViewer default template ships pre-wired to @Client.AlarmPage.* context tags:
json { "Type": "AlarmViewer", "ShowRowSelectorPane": false, "BackgroundTheme": "ControlBackground", "ForegroundTheme": "TextForeground" }
That's the entire cell content. All alarms, all columns, all features — wired.
json { "Type": "AlarmViewer", "Filter": "Area = 'ReactorZone'", "ShowRowSelectorPane": false }
Filter syntax: "Priority >= 2", "Area = 'Tank1'", or a tag binding "@Tag.AlarmFilter".
json "Columns": { "Type": "GridColumnList", "Children": [ { "Type": "GridColumn", "Title": "Active", "FieldName": "ActiveTime_Ticks", "Width": 140 }, { "Type": "GridColumn", "Title": "Tag", "FieldName": "TagName", "Width": 200 }, { "Type": "GridColumn", "Title": "Msg", "FieldName": "Message", "Width": 400 }, { "Type": "GridColumn", "Title": "Pri", "FieldName": "Priority", "Width": 50 } ] }
Available field names: AckStatus, ActiveTime_Ticks, TagName, Group, Value, ID, ItemName, State, AckRequired, Condition, SolutionName, Area, Priority, NormTime_Ticks, AckTime_Ticks, UserName, Message, Duration, Category, DateCreated_Ticks, AuxValue, AlarmLimit, PreviousValue, AuxValue2, AuxValue3. Tick fields format as DateTime at render time.
The canonical asset-driven master-detail pattern. One "detail template" display shows whatever asset the operator picks in the tree.
json { "Type": "AssetsTree" }
The default template already binds:
LinkedValue → @Client.Context.AssetName (output — which asset is selected)AssetPathLink → @Client.Context.AssetPath (output — full path)json { "Type": "ChildDisplay", "DisplayLink": "EquipmentDetailTemplate" }
Inside EquipmentDetailTemplate, every binding uses Asset(@Client.Context.AssetPath + ".Property") or direct @Tag paths constructed from the context. When the operator clicks a different tree node, the ChildDisplay re-renders with the new asset's data.
json { "Type": "ChildDisplay", "DisplayLink": "@Tag.DetailDisplayName" }
A Script calculates which detail display to show based on the selected asset's type (Pump → PumpDetail, Reactor → ReactorDetail) and writes to @Tag.DetailDisplayName. The ChildDisplay swaps automatically.
Both accept the same structure: HeaderElements (navigation chrome) and TabItems (panels). Difference:
AutoCycleLink: 5 for 5-second auto-cycling. Lobby displays, rotating KPI boards.<ac:structured-macro ac:name="code"> <ac:parameter ac:name="language">json</ac:parameter> ac:plain-text-body{ "Type": "Carousel", "AutoCycleLink": 5, "TabItems": [ { "Type": "TabItem", "IsSelected": true, "Header": { "Type": "TextBlock", "LinkedValue": "Production" }, "Children": [ { "Type": "TrendChart", ... } ] }, { "Type": "TabItem", "Header": { "Type": "TextBlock", "LinkedValue": "Quality" }, "Children": [ { "Type": "BarChart", ... } ] }, { "Type": "TabItem", "Header": { "Type": "TextBlock", "LinkedValue": "Alarms" }, "Children": [ { "Type": "AlarmViewer" } ] } ] }</ac:plain-text-body> </ac:structured-macro>
Rules:
IsSelected: trueChildren is an array — can contain multiple elements per tabAutoCycleLink can be a number (seconds) OR a tag binding ("@Tag.ShowroomCycleSeconds") for runtime-configurable cyclingjson { "Type": "CenterValue", "LinkedValue": "@Tag.Plant/ProductionRate", "CenterTextFormat": "N1", "AccentTextLink": "units/hr", "CenterFontSize": 48, "AccentFontSize": 14, "BackgroundTheme": "ControlBackground" }
CenterTextFormat: "N0" (integer), "N1" (1 decimal), "N2" (2 decimals), "P1" (percent 1 decimal). AccentTextLink is the unit or caption under/beside the main number.
json { "Type": "TextBlock", "LinkedValue": "Throughput: {@Tag.Plant/ProductionRate} units/hr ({@Tag.Plant/TargetPct}% of target)", "FontSize": 16, "FontWeight": "SemiBold", "ForegroundTheme": "TextForeground" }
json { "Type": "TextBlock", "LinkedValue": "{@Tag.Plant/AvgTemp} °C", "FontSize": 32, "Dynamics": [ { "Type": "TextColorDynamic", "LinkedValue": "@Tag.Plant/AvgTemp", "ChangeColorItems": [ { "Type": "ChangeColorItem", "ChangeLimit": 0, "LimitColor": "#FF38BDF8" }, { "Type": "ChangeColorItem", "ChangeLimit": 75, "LimitColor": "#FF34D399" }, { "Type": "ChangeColorItem", "ChangeLimit": 90, "LimitColor": "#FFF59E0B" }, { "Type": "ChangeColorItem", "ChangeLimit": 100, "LimitColor": "#FFEF4444" } ] } ] }
Blue cold → green normal → amber warning → red alarm.
Same rule as Canvas: page-to-page navigation belongs in the Header display, not on content pages. Content pages get only in-page interactions.
json { "Name": "Header", "PanelType": "Dashboard", "DashboardDisplay": { "Columns": ["Auto","Auto","Auto","Auto","*","Auto"], "Rows": ["*"], "Cells": [ { "Row": 0, "Col": 0, "Content": { "Type": "Button", "LabelLink": "Overview", "Dynamics": [{"Type":"ActionDynamic","MouseLeftButtonDown":{"Type":"DynamicActionInfo","ActionType":"OpenDisplay","ObjectLink":"OperationsOverview"}}] } }, { "Row": 0, "Col": 1, "Content": { "Type": "Button", "LabelLink": "Alarms", ... } }, { "Row": 0, "Col": 2, "Content": { "Type": "Button", "LabelLink": "Trends", ... } }, { "Row": 0, "Col": 3, "Content": { "Type": "Button", "LabelLink": "Reports", ... } }, { "Row": 0, "Col": 5, "Content": { "Type": "TextBlock", "LinkedValue": "Logged in: {@Client.Username}" } } ] } }
Minimum button size: 100×32. Recommended: 130×40 for touch-friendly control rooms.
For DataGrids, a double-click to drill into detail:
json "Dynamics": [ { "Type": "ActionDynamic", "MouseDoubleClick": { "Type": "DynamicActionInfo", "ActionType": "OpenDisplay", "ObjectLink": "ReactorDetail" } } ]
The target display reads @Tag.SelectedReactor.Name (etc.) — so you get "double-click row → open detail view of that row" with zero code.
RotateDynamic, ScaleDynamic, MoveDragDynamic, SkewDynamic, BargraphDynamic. If you need a rotating element or a custom level-bar, move that cell's content to a Canvas display embedded via ChildDisplay, or pick a control with the behavior built in (LinearGauge for level, symbols for motor-running-spin).Cell.HeaderLink renders in a theme-defined style — you can't set FontSize or Foreground on the header itself from the cell. If you need a custom header, set Cell.HeaderLink to empty string and put a TextBlock as the first element inside the cell Content.@Tag.SelectedValueLink with a pre-populated default.PanelType: "Dashboard" is setDashboardDisplay object includes Columns, Rows, CellsRow and Col; Cell.HeaderLink is either a non-empty string or absentDisplayMember AND SelectedValuePath AND SelectedValueLinkLinkedValue + Strokeget_state after write shows errorList emptytext TextBlock // headers, composite KPIs, status text CenterValue // big-number KPI tiles TrendChart // all time-series (the workhorse) BarChart // categorical comparison AlarmViewer // alarm list with default template AssetsTree // plant navigation sidebar ChildDisplay // embedded detail panel DataGrid // list in list-to-detail ComboBox // FK selector dropdown RadialGauge // circular gauge cells