What is the screen telling you?
The screen is a true-position SPC plot built on a standard Vision chart. It shows a tolerance circle, X deviation on the domain (about -0.003 to +0.003), Y on the range (0.1870 to 0.1930), and the last 5 measured positions joined by one line. The operator can see where the hole is wandering but cannot tell which end of the line is the newest part. Every point has the same color and marker.
The cause is how the chart styles data. A Vision chart is built on JFreeChart, and its renderer assigns paint and shape per series. One X/Y series gives one color and one shape for every item in it. To show progression, you need per-item styling, and the chart does not expose that as a property.
| What the operator sees | Mechanism | Where it gets fixed |
|---|---|---|
| All points the same color | Renderer paint is resolved per series | Override the item paint method, or paint the plot yourself |
| All points the same shape, no numbers | Renderer shape is resolved per series; item labels are off | Override the item shape method, enable item labels, or draw strings on a canvas |
| No clue which point is newest | Dataset row order is not shown anywhere on the plot | Map row index to a color gradient and a printed sequence number |
| Circle looks slightly oval after a resize | Domain and range scale independently | Use one pixels-per-inch scale for both axes |
Which approaches actually work?
Three configurations produce a usable progression plot. They differ in how much you script and in whether the tolerance circle stays geometrically honest.
| Criterion | A: Vision chart + configureChart renderer override |
B: Paintable Canvas, autoscaled to data min/max | C: Paintable Canvas, scaled from nominal and tolerance (h, k, r) |
|---|---|---|---|
| Per-point color by sequence | Yes, override getItemPaint(row, column)
|
Yes | Yes |
| Numbers instead of shapes | Possible through item labels; more work | Trivial with drawString
|
Trivial with drawString
|
| Tolerance circle drawn to scale | Only if axis ranges and aspect are locked | No. The oval is inset from the plot box and the X and Y scales differ | Yes. One scale for both axes and circle radius = r
|
| Reusable across holes with different tolerances | Yes, through axis range bindings | Yes, but the plot rescales on every new sample | Yes, through three custom properties |
| Built-in chart behavior (axes, gridlines) | Kept | You paint everything | You paint everything |
| Script volume | Small for color, larger for numbered labels | About 100 lines | About 100 lines |
A and C both work. C is the right choice for true position. The circle is the specification, so it has to stay round and has to represent the tolerance zone. Any point outside it must look outside it. B looks similar, but it is scaled to the data rather than the drawing callout. A tight cluster fills the whole plot and reads as trouble even when it sits inside a small fraction of the tolerance zone.
How does the configureChart renderer override work?
If the page has to stay on the stock chart, subclass XYLineAndShapeRenderer in the chart's configureChart extension function and swap it into the plot. The renderer calls getItemPaint(row, column) and getItemShape(row, column) for every point. In JFreeChart XY terms, row is the series index and column is the item index within that series.
The row/column mapping is the trap. A mockup that returns Color.RED when row==0 and Color.ORANGE when row==1 colors whole series. With a single series, every point comes out the same color again. The override is in the right place, but it is keyed on the wrong index. For progression, key on column:
from org.jfree.chart.renderer.xy import XYLineAndShapeRenderer
from org.jfree.util import ShapeUtilities
from java.awt import Color
class SequenceRenderer(XYLineAndShapeRenderer):
def getItemPaint(self, row, column):
n = self.getPlot().getDataset().getItemCount(row)
if n < 2:
return Color.GREEN
hue = 120.0 * column / (n - 1) # 0 = red (oldest), 120 = green (newest)
return Color.getHSBColor(hue / 360.0, 1.0, 1.0)
def getItemShape(self, row, column):
n = self.getPlot().getDataset().getItemCount(row)
if column == n - 1:
return ShapeUtilities.createDiagonalCross(5, 1) # newest point stands out
return ShapeUtilities.createDiagonalCross(3, 1)
chart.getXYPlot().setRenderer(SequenceRenderer())
For numbers in place of shapes, turn shapes off on the renderer and enable item labels with a label generator that returns the item index. The setter names for the default label generator changed between JFreeChart releases. Check the JFreeChart version bundled with your Ignition install before you write that call. Replacing the renderer also discards any series styling set on the chart's property sheet, so set line and shape visibility in the subclass if you need them.
Why does the Paintable Canvas win for a true-position plot?
On a Paintable Canvas, the repaint event hands you event.graphics (a Java 2D graphics context), event.width, event.height, and event.source. Everything on the plot is then a coordinate transform plus a draw call:
- Color by sequence is one line: row index to HSB hue.
-
Numbering is one
drawStringper point. -
Different tolerances per template instance are three custom properties. Nominal X (
h), nominal Y (k), and tolerance radius (r) set both axis spans and the circle. - Tick labels use a monospaced font. Every label is then the same width, so right-aligning the range axis is simple arithmetic.
You give up the stock chart's built-in axes and property-sheet styling. For a fixed-format SPC tile that repeats per hole, that is a small cost, because the plot area is just the tolerance square scaled to pixels.
How do I set up the canvas template and its properties?
| Setting | Location | Effect |
|---|---|---|
data (Dataset) with double columns X, Y
|
Canvas custom property, bound to the measurement query or a template parameter | Points to plot; the row order defines the sequence |
h (Double) |
Canvas custom property / template parameter | Nominal X; horizontal center of the circle |
k (Double) |
Canvas custom property / template parameter | Nominal Y; vertical center of the circle |
r (Double) |
Canvas custom property / template parameter | Tolerance zone radius; sets both axis spans and the circle diameter |
| Font | Canvas property font
|
Base size for labels; the title derives from it |
| Repaint script | Component Scripting, repaint event |
Draws everything on each repaint |
- Drop a Paintable Canvas into the Vision template and delete the default repaint code.
- Add the four custom properties above. Promote
h,k,r, and the dataset to template parameters so each instance carries its own hole geometry. - Bind
datato a query that returns the last N measurements, oldest first. If the query uses a newest-first sort to take the last N rows, reverse it: wrap it in an outer query that re-sorts ascending, or reverse the rows in a script transform. Otherwise the gradient runs backwards. - Derive
h,k,rfrom the print for each hole. For the axis limits shown on the original screen (X -0.003 to +0.003, Y 0.1870 to 0.1930), that givesh= 0.000,k= 0.1900,r= 0.003. In that layout X is stored as deviation and Y as an absolute coordinate. Either convention works as long ashandkuse the same one as the columns. - Paste the repaint script from the section below.
- To share or version-control the template on its own, export from the Designer and select only that template. The export dialog lets you choose exactly which resources go into the file, so the rest of the project stays out of it.
How does the repaint script map inches to pixels?
The transform decides whether the circle means anything, so get it right before adding styling.
With one scale for both axes:
scale = circle_diameter_px / (2 * r) # pixels per inch, both axes
x_pixel = x_center + (x - h) * scale
y_pixel = y_center - (y - k) * scale # minus: screen Y grows downward
The first version of this script, generated with an AI assistant, wrote the transform as x_center + (x - (x_min + x_range / 2.0)) * x_scale with separate x_scale and y_scale. When x_min = h - r and x_max = h + r, the midpoint term reduces to h. Both spans equal 2r, so the two scales are identical and the circle stays round. Hard-coded limits (for example -0.003/0.003 and 0.1870/0.1930) lock the template to one hole. Computing the limits from h, k, r removes that restriction.
A later cleanup of the script took min() and max() of the X and Y columns, added a 50% margin, and computed xScale = plotWidth / adjustedDomain and yScale = plotHeight / adjustedRange separately. It then drew the oval as a fixed inset from the plot rectangle. The result looks correct, but the oval has no link to tolerance and the X and Y scales differ whenever the data spread differs. Keep that version's clean Java 2D style and drop its scaling.
For the circle diameter, use the smaller of plot width and plot height times 0.95. That leaves a margin so a point just outside tolerance still lands inside the component. Points more than about 5% beyond r will draw outside the plot box. Either shrink the factor or clamp those points to the box edge and give them an out-of-tolerance marker.
How do I color and number the points by sequence?
The script below combines the fixed-tolerance transform, the gradient, numbering, NaN filtering, and the correct draw order: grid, then circle, then connecting lines, then dots, then labels. The first version drew each connecting line after its dot, so lines crossed over the markers. Drawing all lines first and all dots second fixes that.
from java.awt import BasicStroke, Color, Font, RenderingHints
from java.awt.geom import Ellipse2D, Line2D, Rectangle2D
import math
g = event.graphics
comp = event.source
width = float(event.width)
height = float(event.height)
ds = comp.data # columns X, Y; oldest row first
h = comp.h # nominal X
k = comp.k # nominal Y
r = comp.r # tolerance zone radius
chartTitle = "1 O'CLOCK"
numTicks = 7
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON)
labelFont = Font('Monospaced', Font.PLAIN, comp.font.size)
g.font = labelFont
fm = g.fontMetrics
leftGap = fm.stringWidth('-0.00000') + 12.0
topGap = fm.height * 2.5
bottomGap = fm.height + 10.0
rightGap = 10.0
plotW = width - leftGap - rightGap
plotH = height - topGap - bottomGap
if plotW <= 0 or plotH <= 0 or r is None or r <= 0:
pass # component too small or tolerance not set; draw nothing
else:
diam = min(plotW, plotH) * 0.95
xc = leftGap + plotW / 2.0
yc = topGap + plotH / 2.0
scale = diam / (2.0 * r)
left = xc - diam / 2.0
top = yc - diam / 2.0
# plot square
g.color = Color.WHITE
g.fill(Rectangle2D.Double(left, top, diam, diam))
g.color = Color.LIGHT_GRAY
g.stroke = BasicStroke(1.0)
g.draw(Rectangle2D.Double(left, top, diam, diam))
# gridlines and tick labels, dashed
dash = BasicStroke(1.0, BasicStroke.CAP_BUTT, BasicStroke.JOIN_MITER, 10.0, [5.0, 5.0], 0.0)
for i in range(numTicks):
frac = i / (numTicks - 1.0)
xv = (h - r) + frac * 2.0 * r
yv = (k - r) + frac * 2.0 * r
px = xc + (xv - h) * scale
py = yc - (yv - k) * scale
g.color = Color.LIGHT_GRAY
g.stroke = dash
g.draw(Line2D.Double(px, top, px, top + diam))
g.draw(Line2D.Double(left, py, left + diam, py))
g.color = Color.BLACK
ys = '%.4f' % yv
g.drawString(ys, float(left - 6 - fm.stringWidth(ys)), float(py + fm.ascent / 2.0))
xs = '%.4f' % xv
g.drawString(xs, float(px - fm.stringWidth(xs) / 2.0), float(top + diam + fm.height))
# tolerance circle and axes through nominal
g.color = Color.BLUE
g.stroke = BasicStroke(1.5)
g.draw(Ellipse2D.Double(left, top, diam, diam))
g.stroke = BasicStroke(1.0)
g.draw(Line2D.Double(left, yc, left + diam, yc))
g.draw(Line2D.Double(xc, top, xc, top + diam))
# collect valid points
pts = []
for row in range(ds.rowCount):
x = ds.getValueAt(row, 'X')
y = ds.getValueAt(row, 'Y')
if x is None or y is None or math.isnan(x) or math.isnan(y):
continue
out = math.hypot(x - h, y - k) > r
pts.append((xc + (x - h) * scale, yc - (y - k) * scale, out))
n = len(pts)
colors = []
for i in range(n):
if n > 1:
colors.append(Color.getHSBColor((120.0 * i / (n - 1)) / 360.0, 1.0, 1.0))
else:
colors.append(Color.GREEN)
# connecting lines first
g.stroke = BasicStroke(2.0)
for i in range(1, n):
g.color = colors[i]
g.draw(Line2D.Double(pts[i - 1][0], pts[i - 1][1], pts[i][0], pts[i][1]))
# dots, out-of-tolerance ring, sequence numbers
dot = 8.0
for i in range(n):
px, py, out = pts[i]
g.color = colors[i]
g.fill(Ellipse2D.Double(px - dot / 2.0, py - dot / 2.0, dot, dot))
if out:
g.color = Color.RED
g.draw(Ellipse2D.Double(px - dot, py - dot, dot * 2.0, dot * 2.0))
g.color = Color.BLACK
g.drawString(str(i + 1), float(px + dot), float(py - dot / 2.0))
# title
titleFont = comp.font.deriveFont(Font.BOLD, comp.font.size * 1.6)
g.font = titleFont
tw = g.fontMetrics.stringWidth(chartTitle)
g.drawString(chartTitle, float(xc - tw / 2.0), float(top - fm.height / 2.0))
Two configurations of the gradient both work. A continuous hue sweep from 0 (red, oldest) to 120 (green, newest) shows direction at any N. A short cycled palette such as green, yellow, orange, red indexed by row % 4 is easier to name on the floor, but it repeats after four points. With 50 samples it no longer shows order. Use the sweep, and let the printed numbers give the exact sequence.
To keep imports minimal, use system.gui.color('blue') and the other built-in helpers for colors, and event.source.font with deriveFont for sizing. The cleaned-up version needed only BasicStroke imported. The Ellipse2D/Line2D classes are kept here because they take double coordinates. Integer drawOval/drawLine round every point to whole pixels, which shows at 0.0001 in resolution on a small tile.
What breaks when the script goes live?
| Symptom on screen | Cause | Fix |
|---|---|---|
| Canvas blank, error in console on first open | Dataset empty; min()/max() on an empty column list raises (autoscaled variant) |
Scale from h/k/r, or guard on ds.rowCount
|
| Point drawn at a corner or missing | Null or NaN in X/Y from a failed measurement |
Keep both the None check and math.isnan. One revision dropped the NaN check. |
| Gradient runs newest-red to oldest-green | Query returns newest first | Re-sort ascending by timestamp before binding |
| Labels or points snap and jitter on resize | Jython 2 integer division on int widths |
Cast to float or divide by 2.0
|
| Range labels ragged on the left edge | Proportional font; digit widths vary | Monospaced font plus right alignment using stringWidth
|
| Circle reads as an ellipse relative to the grid | Separate X and Y scales | Single scale = diam / (2r)
|
| Tick labels show long float tails | Raw unicode() of a float, trimmed by character count |
Format with '%.4f' % instead of slicing strings |
| Plot does not update after new part | Data binding not refreshing | Check the query binding's polling; a changed custom property value repaints the canvas |
How do I turn the plot into EDM offset recommendations?
With the history extended to the last 50 points, the plot does more than monitor. On this part, the holes at the 5, 7, and 9 o'clock positions clearly drifted, and the 7 o'clock hole produced the most scrap. The numbered, colored trail separates a steady offset (a tight cluster off nominal) from drift (a colored path walking in one direction) and from scatter (no pattern).
For a steady offset, the correction is the vector from the cluster center back to nominal:
offset_x = h - center_x
offset_y = k - center_y
The recommendation logic used org.apache.commons.math3.ml.clustering, which is callable from Jython, to find the center of the data cluster. Clustering beats a plain mean when the window spans an offset change. The history then holds two populations, and averaging them lands between both. Take the center of the cluster containing the newest points. If the window holds only one population, the arithmetic mean is enough:
xs = [ds.getValueAt(i, 'X') for i in range(ds.rowCount)]
ys = [ds.getValueAt(i, 'Y') for i in range(ds.rowCount)]
cx = sum(xs) / float(len(xs))
cy = sum(ys) / float(len(ys))
recX = h - cx
recY = k - cy
Before entering the correction, confirm the sign convention on the wire EDM. Whether a positive machine offset moves the hole toward +X depends on how the program and work coordinates are set up. Apply half the correction on a trial part if the convention is unproven.
How do I verify the plot is correct?
- Bind
datato a static test dataset with the point (h,k). The dot must sit exactly on the axis crossing. - Add (
h + r,k) and (h,k + r). Both dots must land on the circle, at 3 o'clock and 12 o'clock. If the second lands at 6 o'clock, the Y inversion is missing. - Add (
h + 0.7071r,k + 0.7071r). It must sit on the circle at 45°. If it falls inside or outside, the X and Y scales differ. - Add a point just beyond
r. It must draw the red out-of-tolerance ring. Its true position, 2·√(dx² + dy²), must exceed the diametral tolerance 2r. - Resize the template instance to a tall, narrow shape and then a wide, short shape. The circle must stay round and every test point must stay on it.
- Insert a row with a null
X. The script must skip it without a console error, and numbering must continue on the remaining points. - Bind the live query and confirm point 1 is red and the highest number is green. Then pick one plotted point and check its X/Y against the CMM report for that serial. The dot position read off the grid must match the measured deviation to the fourth decimal.
FAQ
How do I change point colors by sequence in an Ignition Vision chart?
In configureChart, subclass XYLineAndShapeRenderer, override getItemPaint(row, column), and compute the color from column (the item index), not row (the series index). Then install it with chart.getXYPlot().setRenderer(...).
How do I reuse one true-position canvas template for holes with different tolerances?
Add Double custom properties h, k, and r (nominal X, nominal Y, tolerance radius) and promote them to template parameters. Compute the axis limits as h ± r and k ± r and use a single scale of diameter / (2r) for both axes, so the circle always represents the tolerance zone.
How do I label each plotted point with its sample number?
On a Paintable Canvas, call g.drawString(str(i + 1), px + dot, py - dot / 2.0) after drawing the dots, with the dataset sorted oldest first. Verify by comparing the highest-numbered point with the newest CMM record for that hole.