Perspective XY Chart: Configuring Stacked Bar Charts

Tom Garrett11 min read
B&R AutomationHMI / SCADATechnical Reference
Licensed PE Working through this on a live machine? A Maine-licensed engineer can take it from here — included with IMD hardware, by the hour for everything else. Book an engineer

The number that matters is pixels per data point. A column series in Perspective gets its bar width from the plot area divided by the number of x-values in the dataset, and nothing in the Time Series Chart lets you override that division. Feed thirty days of hourly samples into a 900-pixel-wide chart and every bar is under a pixel and a half of fill with a stroke drawn on top of it. That is why a bar chart that looked correct over an eight-hour window degrades into a picket fence of vertical lines when the operator widens the range. This is geometry, not a rendering bug.

Stacking is a separate mechanism entirely, and it fails for a different reason: two series only stack when they share a y-axis object. Assign them to different axes and each series computes its own baseline, so they draw over each other or side by side no matter how many stacked checkboxes are ticked.

Bar Width as a Geometry Problem

The Time Series Chart auto-fits bar width to data density. There is no exposed property for bar width on that component, and the width shrinks linearly as sample count grows:

bar_width_px ≈ (plot_area_width_px / n_x_values) - gap

With a 1140-pixel component showing a day of one-minute alarm counts, 1440 x-values leave less than a pixel per bar. The chart still plots the data correctly — the tooltip returns the right value — but the fill is narrower than the stroke, so the visual reads as a line chart with jitter. Widen the time range and the effect worsens without any change to the query.

The XY Chart takes a different path. Its column series exposes column.appearance.width and column.appearance.height in the props tree, defaulting to null (auto). It is also fed by an explicit dataset with named x and y columns rather than a tag-history binding, so you control point count at the source: aggregate to the bucket you actually want to see. Twenty-four hourly buckets across a day render as twenty-four fat bars regardless of how much raw history sits behind them.

That is the real decision. If you can pre-aggregate, use the XY Chart. If you must show raw history with pan and zoom intact, the Time Series Chart stays, and the width problem gets solved with CSS or by abandoning bars for a stacked area.

Data Shape: One Column per Category

A stacked chart in Perspective is built from N series, each of which reads one y column. It does not pivot long-format data for you. If your query returns one row per alarm with a category field, the chart has no way to split it.

Layout Example columns Works for stacking?
Long / tall t_stamp, category, count No — one series, one color
Wide t_stamp, cat_a, cat_b, cat_c Yes — three column series on one y-axis

Pivot in the named query with conditional aggregation, or in a transform on the binding. Each category gets its own column of totals per x bucket; missing combinations become 0, not NULL, so the stack segment has a defined height. The default dataset that ships with the XY Chart is already wide — t_stamp, process_temp, output_temp — which makes it the fastest way to prove the configuration before wiring a real query.

Approaches Compared

Criterion XY Chart, stacked columns Time Series Chart, bar + CSS width Time Series Chart, stacked area
Bar width control column.appearance.width plus control of point count Fixed pixel width forced by stylesheet N/A — continuous fill
Behavior on wide time ranges Stable if you aggregate to buckets Bars stay wide but overlap and misalign as density rises Degrades gracefully
Built-in zoom / range selector Not the same interaction model Retained Retained
Data source Explicit dataset, wide format Tag history or dataset Tag history or dataset
Alignment to clock boundaries Follows your bucket definition Requires manual transform: translate() tuning Automatic
Effort to maintain Query-side pivot Per-chart CSS class, retuned when layout changes Lowest

For alarm counts, production by shift, downtime by reason code — anything already bucketed — the XY Chart is the correct component. Use it.

XY Chart Procedure

  1. Drag an XY Chart from the Perspective Components palette onto the view. It arrives with a two-series example dataset and two line series.
  2. Open the props tree and expand series. Note the yAxis value on each series. The default configuration puts series[0] on one axis and series[1] on a second, opposite axis.
  3. Set series[1].yAxis to the same axis name as series[0].yAxis — for the default dataset, both become process temp. Repeat for every additional series that belongs in the stack.
  4. Change series[0].render and series[1].render from line to column. The plot redraws as grouped (side-by-side) bars.
  5. Expand series[0].column.appearance and check stacked. Do the same for series[1].column.appearance.stacked. Stacking only takes effect when the flag is true on every series in the group; one series left at false leaves the whole group unstacked.
  6. Set the x-axis render mode to match your data: xAxes[0].render = "date" for timestamps, "category" for discrete labels such as line names or reason codes.
  7. If bars are still narrower than you want, set series[n].column.appearance.width to a fixed value instead of leaving it null, or reduce the number of x-values by aggregating further upstream.
  8. Delete the now-unused second y-axis from yAxes if nothing references it, so the plot area is not reserved for an empty opposite axis.

The resulting series props for one member of the stack look like this:

{
  "name": "process temp",
  "data": { "source": "example", "x": "t_stamp", "y": "process_temp" },
  "xAxis": "time",
  "yAxis": "process temp",
  "render": "column",
  "column": {
    "appearance": {
      "stacked": true,
      "width": null,
      "height": null
    }
  }
}

Verification

Three checks confirm the stack is real and not a coincidence of overlapping bars:

  1. Hover the tallest bar. Each segment returns its own tooltip with its own series name and value; the segment heights sum to the top of the bar rather than each starting at zero.
  2. Click a series entry in the legend to hide it. A true stack collapses — the remaining segments drop down and the bar shortens. Overlapping non-stacked bars simply reveal what was behind them at the same height.
  3. Read the y-axis maximum. On a stack it settles near the maximum sum across buckets. If it settles near the largest single series value, the series are not stacking.

Why Stacking Silently Fails

The failure is almost always axis assignment, and it is silent because both series still plot. Perspective computes stack offsets per axis; a series on its own axis has nothing to stack against, so it draws from its own zero.

Symptom Mechanism Where to read / fix
Bars sit side by side in each bucket stacked false on at least one series series[n].column.appearance.stacked
Bars overlay each other from a common baseline Series bound to different y-axes Compare series[n].yAxis against yAxes[n].name
Scales look wrong, right-hand axis present Second axis has appearance.opposite: true and is still referenced yAxes array
Stack flag set on the wrong node candlestick.appearance.stacked edited instead of column.appearance.stacked Both nodes exist per series; only the one matching render applies
Bars are hairlines Point count too high for plot width Aggregate the query, or set column.appearance.width
One category missing from the stack NULL instead of 0 in that column for that bucket COALESCE in the query

When a chart with stacked: true on every series still refuses to stack, dump the component JSON and diff the yAxis strings across the series array. A configuration carrying axes named y_v and y_m with one series on each will never stack, whatever the checkbox says. Both series must name the same axis.

To capture that JSON cleanly, disable the chart's data binding in the Property Editor first, then right-click the chart in the Project Browser and copy. Leaving the binding live embeds the entire result set in the copied props and buries the eight lines that matter.

Forcing Bar Width on the Time Series Chart

When the zoom and range-selector behavior of the Time Series Chart is non-negotiable, override the rendered SVG geometry from the project stylesheet. The chart emits each bar as a <rect> inside an element carrying the ia_barChart class, so a class-scoped rule pins the width regardless of point count:

.psc-barChart1 .ia_barChart > rect {
  width: 15px;
  transform: translate(10px);
}

.psc-barChart2 .ia_barChart > rect {
  width: 30px;
  transform: translate(20px);
}

Add the class name to the chart's style.classes property (barChart1, without the psc- prefix — Perspective prepends it). The translate term exists because the renderer positions each rect by its computed left edge; widening the rect without shifting it pushes the bar off its tick. Set the translate to roughly half the width you added to recenter, then nudge until the bars line up with the hour boundaries.

Two limits govern this technique. The width is absolute pixels, so it does not survive zoom — zoom in and bars separate, zoom out and they overlap. And the rule is tied to a fixed component size; change the layout and the alignment offset has to be retuned. Treat it as a fixed-window dashboard solution, not a general-purpose chart.

Stacked Area as the Zoom-Friendly Alternative

The cleanest escape from the width problem is to stop drawing bars. Configure the Time Series Chart as a stacked area chart instead of a stacked bar chart: the same cumulative reading, the same category colors, no per-point geometry to collapse. Density that destroys a bar chart improves an area chart, because the fill boundary simply gets smoother. Zoom, pan, and the range selector all keep working, and there is no CSS to maintain.

The trade is interpretive. An area chart implies a continuous quantity between samples; a bar chart implies a discrete bucket total. For alarm counts per hour the bars are more honest, but if your buckets are dense enough that each bar is a pixel wide, that honesty has already been lost to the renderer. Where the data is genuinely sampled — flow, power, tank levels — the stacked area is the better representation anyway.

Total and Segment Labels on the Stack

Neither component draws a stack total natively. The technique is to add a phantom series that carries the total and shows only its bullets:

  1. Add a column to the dataset holding the row sum of every category, and add a series bound to it with the same x field and the same y-axis as the stack.
  2. Set that series render to a step line, then set its stroke width to 0 so the connecting line never draws.
  3. Change its bullets from circles to labels so each point renders as text.
  4. Adjust the bullet dy offset until the labels clear the top of each column. A negative offset moves the text up; tune it against the tallest bar so nothing clips at the plot boundary.

For richer labels — a total with the individual contributions in brackets, or mixed weights and colors in one string — the label text field accepts amCharts inline formatting codes. Build the composite string in the dataset column, or reference multiple data fields in the label expression, and the renderer applies the styling tags.

Keep the phantom series out of the legend by setting its hiddenInLegend flag, otherwise operators can toggle off the labels and wonder where they went. And confirm the phantom series is not itself set to stacked — a stacked step line would sit at the sum of the sum.

Aggregation, Not Decoration

Every workaround above treats a symptom of point count. The durable fix is upstream: decide the bucket the operator actually reads — hour, shift, day — and aggregate to it in the named query with a GROUP BY on a truncated timestamp. Twenty to sixty buckets across the visible range gives bars wide enough to click, tooltips wide enough to hit on a touch panel, and a payload small enough that the browser does not stall re-rendering on every polled update. A chart pushing thousands of x-values through the session is spending bandwidth and client CPU to draw sub-pixel rectangles.

Pick the bucket first, pivot to wide format second, then configure the chart. The property changes take under a minute once the dataset is right.

When to Escalate

Stop debugging locally when a copied props JSON shows every series on one y-axis, render set to column, and column.appearance.stacked true on all of them, and the chart still draws unstacked — that combination is a component defect, not a configuration error. Capture the component JSON with the data binding disabled, a screenshot of the rendered chart, and your Ignition and browser versions, and open a ticket with Inductive Automation support. The same applies to bar-width control on the Time Series Chart: an exposed width property has been raised as a feature request rather than being a setting you have missed, so check the release notes for your version before building a CSS workaround you will have to maintain.

FAQ

What happens if two series have stacked set to true but sit on different y-axes?

They will not stack. Perspective computes stack offsets per axis, so each series draws from its own baseline and the bars overlay or group instead. Set series[1].yAxis to the same axis name as series[0].yAxis.

What happens if I use the Time Series Chart bar type over a wide time range?

Bar width is auto-calculated as plot width divided by point count, so a wide range with dense data produces sub-pixel bars that read as thin vertical lines. Switch to the XY Chart with aggregated buckets, force a fixed rect width from the stylesheet, or use a stacked area chart instead.

What happens if my dataset is in long format with a category column?

You get a single-color series with no stack, because each series reads exactly one y column. Pivot the query so each category has its own numeric column per x bucket, and fill missing combinations with 0 rather than NULL.

What happens if I want the stack total printed above each bar?

Add a series bound to a total column, render it as a step line with stroke width 0, switch its bullets to labels, and offset the bullet dy so the text clears the top of the column. Flag it hiddenInLegend so operators cannot toggle the labels off.

Back to blog