Working on a Java(FX) port of my hoplite simulator Myriarch, I ran into an unexpected problem with the history chart. This part of the “Simulation Report” dialog shows how each faction’s unit count changes over the course of the simulation. You can see a screenshot from the current build below.
The control is a standard JavaFX LineChart, a subclass of the versatile Chart API available since JavaFX 2. The original .NET version of Myriarch had used a custom chart. The good news is that LineChart
does exactly what I want, so I could save quite a bit of work.
CSS Chart Styling
The bad news is that the JavaFX Chart
API lacks one crucial feature, namely customizing the colors of the lines and symbols (circles) for each data series. I wanted them to match the faction colors defined by the simulation which are known only at runtime.
But Chart
only supports styling through the JavaFX CSS mechanism, as described in Styling Charts with CSS. All examples in that article use a static CSS stylesheet to define custom colors and shapes. Useless to me, as I would have to build an actual disk file at runtime and load it as a resource.
JavaFX display nodes do offer a style property to set CSS styles at runtime – but only for the specific node the method is called on. This property does not accept CSS class selectors and is not inherited by sub-nodes, so attempting to set line or symbol colors on the LineChart
itself does nothing.
Coloring Lines and Symbols
That is because lines and symbols are represented by sub-nodes nested deeply within the display structure of the LineChart
. So in order to change their colors at runtime, we must first build the LineChart
and then use the lookup(All) inspection method to find the actual display nodes for lines and symbols.
The official Oracle documentation is unfortunately silent on this, but John Smith (Jewelsea) has posted a number of replies with sample code on Stack Overflow which helped me along. You can find the complete method from the current Myriarch build below. Here are the necessary steps:
- Create a
LineChart
and fetch each dataSeries
, here prebuilt for each simulation faction. - Add the
Series
to theLineChart
. That is important, as this adding triggers the creation of display nodes within theSeries
. - Get the desired color for the
Series
and convert it to a CSSrgba
string. - Look up the (single) node with a CSS class named
.chart-series-line
within theSeries
node. This is the display node for the line. Set its color via-fx-stroke
. - Iterate through all
Data
items within theSeries
. Each symbol (here the default circle) painted over theSeries
line corresponds to oneData
item. - Look up the (single) node with a CSS class named
.chart-line-symbol
within eachData
node. This is the display node for the symbol. Set its color via-fx-background-color
(!). The secondary color (herewhitesmoke
) is the interior of the symbol.
Coloring the Chart Legend
So far for the chart’s data display. However, we also want to show a legend that explains what the colors represent. And unlike with the CSS stylesheet procedure, the symbols in the legend are not affected by the above changes. Once again they use separate display nodes that must be tracked down individually.
Here I found an additional problem. While the display nodes for Series
and Data
nodes are created as soon as a Series
is added to the LineChart
, the legend is not actually created until the chart is added to a Scene
, i.e. the dialog’s top layout node.
You could simply add the requisite code after the chart has been added to the Scene
in the dialog’s constructor, but I prefer to have the entire chart creation encapsulated in one method. So instead I used Platform.runLater
to ensure that legend nodes are looked up only after the dialog has been built. Here’s what happens then:
- Look up all nodes with a CSS class named
.chart-legend-item-symbol
within theLineChart
. These are the display nodes for symbols within the legend. - But which legend symbol corresponds to which
Series
? To find out, iterate through all CSS style classes defined on the display node. - One of those classes is of course
.chart-legend-item-symbol
but we are interested in classes of the formseries<i>N</i>
where<i>N</i>
is an integer number. These numbers count up from zero, indicating the order in which eachSeries
has been added. - Importantly, this number may differ from any external identifiers used in constructing the
Series
(here faction keys). So theSeries
insertion order is tracked by a separatesymbolStyles
array while building the chart. - Split off and parse the
<i>N</i>
part of theseries<i>N</i>
class name. Set the current node’s color to the correspondingsymbolStyles
element.
/**
* Creates a {@link Unit} chart for the specified {@link Faction} map.
* @param units a {@link Map} that maps {@link Faction} keys to the corresponding
* {@link XYChart.Series} of present or steady {@link Unit} objects
* @return the {@link LineChart} for the specified {@code units}
* @throws NullPointerException if {@code units} is {@code null}
*/
private LineChart<Number, Number> createChart(Map<Integer, XYChart.Series<Number, Number>> units) {
final LineChart<Number, Number> chart = new LineChart<>(new NumberAxis(), new NumberAxis());
chart.setCreateSymbols(true);
final Collection<Faction> factions = _simulation.factions.collection().values();
final String[] symbolStyles = new String[factions.size()];
int index = 0;
for (Faction faction: factions) {
final XYChart.Series<Number, Number> series = units.get(faction.key());
chart.getData().add(series);
// convert Faction color to CSS format
final String color = String.format("rgba(%d, %d, %d, 1.0)",
faction.colorRed(), faction.colorGreen(), faction.colorBlue());
// set line color on Series node
final String lineStyle = String.format("-fx-stroke: %s;", color);
series.getNode().lookup(".chart-series-line").setStyle(lineStyle);
// set symbol color on Data nodes, remember for legend nodes
final String symbolStyle = String.format("-fx-background-color: %s, whitesmoke;", color);
symbolStyles[index++] = symbolStyle;
for (XYChart.Data<Number, Number> data: series.getData())
data.getNode().lookup(".chart-line-symbol").setStyle(symbolStyle);
}
/*
* Set remembered symbol colors (in Chart insertion order) on legend nodes.
* We must use runLater here because the legend is only created on display.
*/
Platform.runLater(() -> {
for (Node node: chart.lookupAll(".chart-legend-item-symbol"))
for (String styleClass: node.getStyleClass())
if (styleClass.startsWith("series")) {
final int i = Integer.parseInt(styleClass.substring(6));
node.setStyle(symbolStyles[i]);
break;
}
});
return chart;
}
One Final Problem
The dialog requires charts for two different data sets, using identical axes. Initially I tried to reproduce the .NET implementation where only one chart element existed, and radio buttons selected which data to show. In response I cleared the chart’s data and re-added the requisite Series
set.
That did not work out too well. The LineChart
would correctly display the first data set, then the second data set after toggling. Any further switching between data sets would result in the loss of all symbols overlaid on the data lines. I reckon this is because Series
holds internal information related to its LineGraph
and gets confused when it is removed and later re-added.
Perhaps a workable solution would be to clear each Series
and manually reinsert Data
items instead. For now, though, I simply switched my implementation to creating two LineGraph
objects, one for each data set, and leaving them alone after construction.
Thanks very much for this, I was going round in circles trying to use -fx-stroke on the symbols, but your comment: “Set its color via -fx-background-color (!). The secondary color (here whitesmoke) is the interior of the symbol.”
Was the missing info!
Cheers!
Glad you found the article useful. Yes, it’s basically impossible to guess that you need to set a “background” color to change the color of the circle outline…
Hello,
about that axis. If you want numbers in horizintal axis, you have to change CategoryAxis to NumberAxis by editing FXML file using a text editor or IDE. Before you do that, it’s recommende to close SceneBuilder.
Can you make the interior of the symbol transparent so that you can see other data points behind it?
If you can’t use runLater, you can use lineChart.applyCss(); before coloring the legend.
But there’s an even simpler way!
One commenter in Jewelsea’s gist found out you can write simply:
lineChart.setStyle(“CHART_COLOR_1: #0000FF;”);
and it will apply to everything in the chart, including the legend.