LineChart FX - Delete solid line - javafx

I have a curious question about the graph LineChart JavaFX.
I have this graph:
dots forming a "jump" on the X axis (as shown by the two red points I scored) and therefore JavaFX draws me the line between these two points. How do I remove that line between each "jump"?
I post the code:
public class ControllerIndividua {
public static void plotIndividuaFull(String path, Stage stage, String name) {
final NumberAxis xAxisIntensity = new NumberAxis(); //Dichiarazione asseX
final NumberAxis yAxisIntensity = new NumberAxis();//Dichiarazione asseY
DetectionS1.countS1();
//Dichiarazione del tipo di grafico
final LineChart<Number, Number> lineChartIntensity = new LineChart<Number, Number>(xAxisIntensity,yAxisIntensity);
ArrayList<Double> extractedData; //Lista dei valori dello dell' intensità
ArrayList<Double> extractedTime; //Lista del tempo
ArrayList<Double> extractedS1; //Lista del tempo
ArrayList<Double> extractedS1Time; //Lista del tempo
//Gestione e settaggio del grafico
lineChartIntensity.getData().clear();
try {
//Popolamento delle liste
extractedTime = IntensityExtractor.pointsTime();
extractedData = IntensityExtractor.pointsIntensity();
extractedS1 = DetectionS1.S1pitch();
extractedS1Time = DetectionS1.pointsS1Time();
XYChart.Series<Number, Number> series = new XYChart.Series<Number, Number>();
XYChart.Series<Number, Number> seriesS1 = new XYChart.Series<Number, Number>(); //Creazione seconda serie
series.setName("Intensità di:\t" + name.toUpperCase());
for (int j = 0; j < extractedS1.size(); j++) {
seriesS1.getData().add(new XYChart.Data<Number, Number>(extractedS1Time.get(j), extractedS1.get(j)));
lineChartIntensity.getStyleClass().add("CSSintensity");
}
//Creazione finestra e stampa del grafico
Scene scene = new Scene(lineChartIntensity, 1000, 600);
lineChartIntensity.getData().addAll(series,seriesS1);
scene.getStylesheets().add("application/application.css");
stage.setScene(scene);
stage.show();
} catch (java.lang.Exception e) {
e.printStackTrace();
}
}
}
Someone also has a little idea on how I could do?
Thank you all in advance.

This is an old question with an accepted answer but I came across it and was curious. I wanted to know if it was possible to put a gap in a LineChart (at least without having to create a custom chart implementation). It turns out that there is. The solution is kind of hacky and brittle. It involves getting the Path by using XYChart.Series.getNode() and manipulating the list of PathElements. The following code gives an example:
import javafx.application.Application;
import javafx.application.Platform;
import javafx.scene.Scene;
import javafx.scene.chart.LineChart;
import javafx.scene.chart.NumberAxis;
import javafx.scene.chart.XYChart;
import javafx.scene.layout.StackPane;
import javafx.scene.shape.LineTo;
import javafx.scene.shape.MoveTo;
import javafx.scene.shape.Path;
import javafx.stage.Stage;
public class Main extends Application {
#Override
public void start(Stage primaryStage) {
LineChart<Number, Number> chart = new LineChart<>(new NumberAxis(), new NumberAxis());
chart.getXAxis().setLabel("X");
chart.getYAxis().setLabel("Y");
chart.setLegendVisible(false);
chart.getData().add(new XYChart.Series<>());
for (int x = 0; x <= 10; x++) {
chart.getData().get(0).getData().add(new XYChart.Data<>(x, Math.pow(x, 2)));
}
/*
* Had to wrap the call in a Platform.runLater otherwise the Path was
* redrawn after the modifications are made.
*/
primaryStage.setOnShown(we -> Platform.runLater(() -> {
Path path = (Path) chart.getData().get(0).getNode();
LineTo lineTo = (LineTo) path.getElements().get(8);
path.getElements().set(8, new MoveTo(lineTo.getX(), lineTo.getY()));
}));
primaryStage.setScene(new Scene(new StackPane(chart), 500, 300));
primaryStage.setTitle("LineChart Gap");
primaryStage.show();
}
}
This code results in the following:
This is possible because the ObservableList of PathElements seems to be a MoveTo followed by a bunch of LineTos. I simply picked a LineTo and replaced it with a MoveTo to the same coordinates. I haven't quite figured out which index of LineTo matches with which XYChart.Data, however, and picked 8 for the example randomly.
There are a couple of issues with this solution. The first and obvious one is that this relies on the internal implementation of LineChart. The second is where the real brittleness comes from though. Any change to the data, either axes' value ranges, the chart's width or height, or pretty much anything that causes the chart to redraw itself will cause the Path to be recomputed and redrawn. This means if you use this solution you'll have to reapply the modification every time the chart redraws itself.

I made a GapLineChart that automatically sets a MoveTo like Slaw pointed it whenever it sees a Double.NaN. You can find it here https://gist.github.com/sirolf2009/ae8a7897b57dcf902b4ed747b05641f9. Check the first comment for an example

I checked it. It is not possible to change only one part of this line. Because this is one long Path and you can't change one element of Path.
I think the only way is add each "jump" in different series.

I was in need of adapting LineChart so that it drew line segments, so each pair of data points created a single line segment. It's pretty straight-forward to do by extending LineChart and overriding the layoutPlotChildren function, although I had to delete an animation y-scaling variable as it was private in the super class, but I'm not animating the graph so it was fine. Anyway, here is the Kotlin version (which you can easily adapt to Java, or just copy the Java LineChart layoutPlotChildren code and adapt). It's easy to adapt to your circumstances, just change the contents of the for loop at the very end.
import javafx.scene.chart.Axis
import javafx.scene.chart.LineChart
import javafx.scene.shape.LineTo
import javafx.scene.shape.MoveTo
import javafx.scene.shape.Path
import java.util.*
import kotlin.collections.ArrayList
class GapLineChart<X, Y>(xAxis: Axis<X>?, yAxis: Axis<Y>?) : LineChart<X, Y>(xAxis, yAxis) {
public override fun layoutPlotChildren() {
val constructedPath = ArrayList<LineTo>(data.size)
for (seriesIndex in 0 until data.size) {
val series = data[seriesIndex]
if (series.node is Path) {
val seriesLine = (series.node as Path).elements
val it = getDisplayedDataIterator(series)
seriesLine.clear()
constructedPath.clear()
var parity = true
while (it.hasNext()) {
val item = it.next()
val x = xAxis.getDisplayPosition(item.xValue)
val y = yAxis.getDisplayPosition(yAxis.toRealValue(yAxis.toNumericValue(item.yValue)))
if (java.lang.Double.isNaN(x) || java.lang.Double.isNaN(y)) {
continue
}
if(parity)
constructedPath.add(LineTo(x, y))
val symbol = item.node
if (symbol != null) {
val w = symbol.prefWidth(-1.0)
val h = symbol.prefHeight(-1.0)
symbol.resizeRelocate(x - w / 2, y - h / 2, w, h)
}
}
when (axisSortingPolicy) {
SortingPolicy.X_AXIS -> constructedPath.sortWith(Comparator { e1, e2 -> java.lang.Double.compare(e1.x, e2.x) })
SortingPolicy.Y_AXIS -> constructedPath.sortWith(Comparator { e1, e2 -> java.lang.Double.compare(e1.y, e2.y) })
}
if (!constructedPath.isEmpty()) {
for (i in 0 until constructedPath.size-1 step 2) {
seriesLine.add(MoveTo(constructedPath[i].x, constructedPath[i].y))
seriesLine.add(LineTo(constructedPath[i+1].x, constructedPath[i+1].y))
}
}
}
}
}
}

Related

Erasing Antialiased Shapes from a JavaFX Canvas

I have inherited a simulation program to extend with new features. The original was written as an Applet using the AWT library for graphics. Before adding the new features I want to adapt the program to the desktop and use JavaFX instead of AWT.
The simulation paints hundreds or thousands of objects dozens of times per second, then erases them and repaints them at new locations, effectively animating them. I am using a Canvas object for that part of the UI. Erasing is done by repainting the object with the background color. What I am seeing though is that erasing objects is incomplete. A kind of "halo" gets left behind though.
The following program illustrates the problem. Clicking the "Draw" button causes it to draw a few hundred circles on the Canvas using the foreground color. After drawing, clicking the button again will erase the circles by re-drawing them in the background color. Multiple cycles of draw/erase will build up a visible background of "ghost" images.
package com.clartaq.antialiasingghosts;
import javafx.application.Application;
import javafx.application.Platform;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Scene;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.stage.Stage;
import java.util.Random;
public class Main extends Application {
static final int NUM_CIRCLES = 500;
static final int CIRCLE_DIAMETER = 10;
static final double PANEL_WIDTH = 75.0;
static final double PANEL_HEIGHT = 40.0;
static final Color FG_COLOR = Color.rgb(10, 0, 200);
static final Color BG_COLOR = Color.rgb(255, 255, 255);
static final double BUTTON_WIDTH = 50.0;
GraphicsContext gc;
Random rand = new Random();
double[] px = new double[NUM_CIRCLES];
double[] py = new double[NUM_CIRCLES];
void randomizeParticlePositions() {
for (int i = 0; i < NUM_CIRCLES; i++) {
px[i] = rand.nextDouble() * PANEL_WIDTH;
py[i] = rand.nextDouble() * PANEL_HEIGHT;
}
}
void drawCircles(Color color) {
gc.setFill(color);
for (int i = 0; i < NUM_CIRCLES; i++) {
var screenX = px[i] * CIRCLE_DIAMETER;
var screenY = py[i] * CIRCLE_DIAMETER;
gc.fillOval(screenX, screenY, CIRCLE_DIAMETER, CIRCLE_DIAMETER);
}
}
#Override
public void start(Stage stage) {
String javaVersion = System.getProperty("java.version");
String javafxVersion = System.getProperty("javafx.version");
stage.setTitle("AntiAliasingGhosts -- erasing objects leaves ghosts in JavaFX");
Label versionLabel = new Label("JavaFX " + javafxVersion
+ ", running on Java " + javaVersion + ".");
double canvasWidth = (PANEL_WIDTH * CIRCLE_DIAMETER);
double canvasHeight = (PANEL_HEIGHT * CIRCLE_DIAMETER);
Canvas canvasRef = new Canvas(canvasWidth, canvasHeight);
gc = canvasRef.getGraphicsContext2D();
Button deBtn = new Button("Draw");
deBtn.setPrefWidth(BUTTON_WIDTH);
deBtn.setOnAction(e -> {
String txt = deBtn.getText();
switch (txt) {
case "Draw" -> {
randomizeParticlePositions();
drawCircles(FG_COLOR);
deBtn.setText("Erase");
}
case "Erase" -> {
drawCircles(BG_COLOR);
deBtn.setText("Draw");
}
default -> Platform.exit();
}
});
Button exBtn = new Button("Exit");
exBtn.setPrefWidth(BUTTON_WIDTH);
exBtn.setOnAction(e -> Platform.exit());
TilePane tp = new TilePane();
tp.setAlignment(Pos.CENTER);
tp.setHgap(10);
tp.getChildren().addAll(deBtn, exBtn);
VBox root = new VBox();
root.setPadding(new Insets(7));
root.setSpacing(10);
root.setAlignment(Pos.CENTER);
root.getChildren().addAll(versionLabel, canvasRef, tp);
StackPane sp = new StackPane(root);
BackgroundFill bf = new BackgroundFill(BG_COLOR, CornerRadii.EMPTY, Insets.EMPTY);
Background bg = new Background(bf);
sp.setBackground(bg);
Scene scene = new Scene(sp, 640.0, 480.0);
stage.setScene(scene);
stage.show();
}
public static void main(String[] args) {
launch();
}
}
I can get good erasure by expanding the diameter of the circles by 2 pixels when erasing. Of course, that can affect nearby shapes too.
Also, using the fillRect method to erase the entire Canvas seems reasonable, but that means everything has to be re-drawn if anything has to be re-drawn. I suppose it is possible to optimize the re-draw by erasing and re-drawing a smaller section of the Canvas but I don't want to do that if it isn't necessary.
Magnifying sections of the program display shows that it is really an antialiasing effect. Constructing the Scene with the SceneAntialiasing.DISABLED parameter does not seem to have any effect.
Attempting to turn off image smoothing as suggested in this question does not help.
Is possible to erase a single shape drawn on a Canvas by re-drawing it in the background color?
I am using Java 17.0.1, JavaFX 17.0.1, and a 5K Mac display if that is relevant.
For expedience, note the difference between fillOval and strokeOval() in the GraphicsContext. You can conditionally erase the outline in drawCircles() as a function of a suitable boolean value:
if (stroke) {
gc.setStroke(BG_COLOR);
gc.strokeOval(screenX, screenY, CIRCLE_DIAMETER, CIRCLE_DIAMETER);
}
Try a few representative shapes, e.g. fillRect, to verify the desired result.
A better alternative, IMO, is to pursue the erase -> render strategy. Complete examples seen here and here may help you establish whether the approach is scalable to your use-case. See also this related examination of resampling artifact.
Expedient approach, as tested:
import javafx.application.Application;
import javafx.application.Platform;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Scene;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.stage.Stage;
import java.util.Random;
public class Main extends Application {
static final int NUM_CIRCLES = 500;
static final int CIRCLE_DIAMETER = 10;
static final double PANEL_WIDTH = 75.0;
static final double PANEL_HEIGHT = 40.0;
static final Color FG_COLOR = Color.rgb(10, 0, 200);
static final Color BG_COLOR = Color.rgb(255, 255, 255);
static final double BUTTON_WIDTH = 50.0;
GraphicsContext gc;
Random rand = new Random();
private boolean stroke;
double[] px = new double[NUM_CIRCLES];
double[] py = new double[NUM_CIRCLES];
void randomizeParticlePositions() {
for (int i = 0; i < NUM_CIRCLES; i++) {
px[i] = rand.nextDouble() * PANEL_WIDTH;
py[i] = rand.nextDouble() * PANEL_HEIGHT;
}
}
void drawCircles(Color color) {
gc.setFill(color);
for (int i = 0; i < NUM_CIRCLES; i++) {
var screenX = px[i] * CIRCLE_DIAMETER;
var screenY = py[i] * CIRCLE_DIAMETER;
gc.fillOval(screenX, screenY, CIRCLE_DIAMETER, CIRCLE_DIAMETER);
if (stroke) {
gc.setStroke(BG_COLOR);
gc.strokeOval(screenX, screenY, CIRCLE_DIAMETER, CIRCLE_DIAMETER);
}
}
}
#Override
public void start(Stage stage) {
String javaVersion = System.getProperty("java.version");
String javafxVersion = System.getProperty("javafx.version");
stage.setTitle("AntiAliasingGhosts -- erasing objects leaves ghosts in JavaFX");
Label versionLabel = new Label("JavaFX " + javafxVersion
+ ", running on Java " + javaVersion + ".");
double canvasWidth = (PANEL_WIDTH * CIRCLE_DIAMETER);
double canvasHeight = (PANEL_HEIGHT * CIRCLE_DIAMETER);
Canvas canvasRef = new Canvas(canvasWidth, canvasHeight);
gc = canvasRef.getGraphicsContext2D();
Button deBtn = new Button("Draw");
deBtn.setPrefWidth(BUTTON_WIDTH);
deBtn.setOnAction(e -> {
String txt = deBtn.getText();
switch (txt) {
case "Draw" -> {
randomizeParticlePositions();
drawCircles(FG_COLOR);
deBtn.setText("Erase");
stroke = true;
}
case "Erase" -> {
drawCircles(BG_COLOR);
deBtn.setText("Draw");
stroke = false;
}
default ->
Platform.exit();
}
});
Button exBtn = new Button("Exit");
exBtn.setPrefWidth(BUTTON_WIDTH);
exBtn.setOnAction(e -> Platform.exit());
TilePane tp = new TilePane();
tp.setAlignment(Pos.CENTER);
tp.setHgap(10);
tp.getChildren().addAll(deBtn, exBtn);
VBox root = new VBox();
root.setPadding(new Insets(7));
root.setSpacing(10);
root.setAlignment(Pos.CENTER);
root.getChildren().addAll(versionLabel, canvasRef, tp);
StackPane sp = new StackPane(root);
BackgroundFill bf = new BackgroundFill(BG_COLOR, CornerRadii.EMPTY, Insets.EMPTY);
Background bg = new Background(bf);
sp.setBackground(bg);
Scene scene = new Scene(sp, 640.0, 480.0);
stage.setScene(scene);
stage.show();
}
public static void main(String[] args) {
launch();
}
}

JavaFX PieChart Legend Color change

I need to change color of circles in PieChart Legend. I don't know how to get to this property of PieChart. For example I'm able to change color of text in label Legend and I think this is close to the solution.
It shows what I want to change:
#FXML
public PieChart chart;
public ObservableList<PieChart.Data> pieChartData = FXCollections.observableArrayList();
public void chartLoad() {
pieChartData.clear();
List<String> colorList = new ArrayList<>();
for(int i = 0; i < categoryList.getSize(); i++) {
if(categoryList.getByIndex(i).getValue() > 0) {
PieChart.Data data = new PieChart.Data(categoryList.getByIndex(i).getName(),
categoryList.getByIndex(i).getValue());
pieChartData.add(data);
data.getNode().setStyle("-fx-pie-color: " +
categoryList.getByIndex(i).getColor().getName());
colorList.add(categoryList.getByIndex(i).getColor().getName());
}
}
Set<Node> items = chart.lookupAll("Label.chart-legend-item");
int i = 0;
for(Node item : items) {
Label label = (Label) item;
label.setText("sampleText");
label.setStyle("-fx-text-fill: " + colorList.get(i));
System.out.println(label.getChildrenUnmodifiable().toString());
i++;
}
chart.setData(pieChartData);
}
Thank you for your future comments and answers.
Dynamically allocating colors to charts is a bit of a pain. If you have a fixed set of colors, without a predefined mapping from your data to the colors, you can just use an external style sheet, but doing anything else needs (as far as I know) a bit of a hack.
The default modena.css style sheet defines eight constant colors, CHART_COLOR_1 to CHART_COLOR_8. Nodes in a pie chart, including both the "pie slices" and the color swatches in the legend, are assigned a style class from the eight classes default-color0 to default-color7. Each of these style classes by default has -fx-pie-color set to one of the constants. Unfortunately, if the data in the pie chart are changed, these mappings from default-colorx to CHART_COLOR_y change in a way that isn't documented.
So the best approach for your scenario that I can find is:
add the new data to the chart
once all the data are added, for each datum look up the style class that was added to the datum's node
look up all the nodes in the chart that have that style class (this will also give the legend swatches)
update the -fx-pie-color for those nodes to the desired color
The last trap here is that you need to make sure the legend is added to the chart, and that CSS is applied to the chart, so that the lookup works.
public void chartLoad() {
pieChartData.clear();
List<String> colors = new ArrayList<>();
for(int i = 0; i < categoryList.getSize(); i++) {
if(categoryList.getByIndex(i).getValue() > 0) {
PieChart.Data data = new PieChart.Data(categoryList.getByIndex(i).getName(),
categoryList.getByIndex(i).getValue());
pieChartData.add(data);
colors.add(categoryList.getByIndex(i).getColor().getName());
}
}
chart.setData(pieChartData);
chart.requestLayout();
chart.applyCSS();
for (int i = 0 ; i < pieChartData.size() ; i++) {
PieChart.Data d = pieChartData.get(i);
String colorClass = "" ;
for (String cls : d.getNode().getStyleClass()) {
if (cls.startsWith("default-color")) {
colorClass = cls ;
break ;
}
}
for (Node n : chart.lookupAll("."+colorClass)) {
n.setStyle("-fx-pie-color: "+colors.get(i));
}
}
}
Here's a quick, complete demo of this approach:
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import javafx.application.Application;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.chart.PieChart;
import javafx.scene.control.Button;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.paint.Color;
import javafx.stage.Stage;
public class PieChartTest extends Application {
private final Random rng = new Random();
#Override
public void start(Stage primaryStage) throws Exception {
PieChart chart = new PieChart();
Button button = new Button("Generate Data");
button.setOnAction(e -> updateChart(chart));
BorderPane root = new BorderPane(chart);
HBox controls = new HBox(button);
controls.setAlignment(Pos.CENTER);
controls.setPadding(new Insets(5));
root.setTop(controls);
Scene scene = new Scene(root, 600, 600);
primaryStage.setScene(scene);
primaryStage.show();
}
private void updateChart(PieChart chart) {
chart.getData().clear();
int numValues = 4 + rng.nextInt(10);
List<String> colors = new ArrayList<>();
List<PieChart.Data> data = new ArrayList<>();
for (int i = 0 ; i < numValues ; i++) {
colors.add(getRandomColor());
PieChart.Data d = new PieChart.Data("Item "+i, rng.nextDouble() * 100);
data.add( d );
chart.getData().add(d) ;
}
chart.requestLayout();
chart.applyCss();
for (int i = 0 ; i < data.size() ; i++) {
String colorClass = "" ;
for (String cls : data.get(i).getNode().getStyleClass()) {
if (cls.startsWith("default-color")) {
colorClass = cls ;
break ;
}
}
for (Node n : chart.lookupAll("."+colorClass)) {
n.setStyle("-fx-pie-color: "+colors.get(i));
}
}
}
private String getRandomColor() {
Color color = Color.hsb(rng.nextDouble() * 360, 1, 1);
int r = (int) (255 * color.getRed()) ;
int g = (int) (255 * color.getGreen());
int b = (int) (255 * color.getBlue()) ;
return String.format("#%02x%02x%02x", r, g, b) ;
}
public static void main(String[] args) {
Application.launch(args);
}
}
This really is a bit of a hack, so better solutions are obviously welcome.

JavaFX XYChart's weird behaviour on replacing all series

once again, I scratch my head while working with JavaFX-Charts... :-(
I have a usecase for which I need to replace all series in the chart, I create everything from scratch. When adding after clearing the previous data, the chart assigns the wrong style-classes for default-color to some of the added series...
More precisely, the assigned class-numbers get shifted... why on earth did they implement that with modulo, which by default limits to only 8 (!) different color-symbol-combinations...?!
The first 8 (hint: mod 8...) default-colors are correct:
default-color0
...
default-color7
and for the first run, it continues, also correct, again with 0:
default-color0
...
default-color6 (for 15 series in my example)
for the second run, I get:
default-color0
...
default-color7
default-color7 <-- shift from here
default-color0
...
default-color5
as you can see, the it is shifted, having two times 7 and then only running until 5.
For another run, the shift continues:
default-color0
...
default-color7
default-color6 <-- shift from here
default-color7
default-color0
...
default-color4
the amount of shifting is dependent on the number of series - for 16, everything is fine, for 14, it shifts 2 each time and so on.
In the chart this is nicely visible if you watch the legend changing...
I tried different methods of replacing the data, but none of them was successful, see comments in the code below.
An actual solution, but in my eyes not a very pretty one, is to manually overwrite the style-class, which isn't that easy as well, because of the aggressive habits of the task-class... wtf?!
whatever - if anyone has any helpful idea on that? maybe I'm missing something?
Note: To see the weired results, remove the lines marked as DIRTY-WORKAROUND-SOLUTION. Otherwise it would work... ;-)
Bonus-Question: maybe I'm silly, working with that JavaFX-Charts... some things are cool, but often times, I have a weired feeling about it... are there better alternatives you would recommend me to checo out?
Thanks!
import javafx.application.Application;
import javafx.beans.InvalidationListener;
import javafx.scene.Scene;
import javafx.scene.chart.NumberAxis;
import javafx.scene.chart.ScatterChart;
import javafx.scene.chart.XYChart;
import javafx.scene.control.Button;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;
import java.util.ArrayList;
import java.util.List;
public class ChartSeriesReplacing extends Application {
#Override
public void start(Stage primaryStage) throws Exception {
VBox root = new VBox();
ScatterChart<Number, Number> chart = new ScatterChart<>(new NumberAxis(), new NumberAxis());
Button refresh = new Button("Refresh");
refresh.setOnAction(clicked -> {
List<XYChart.Series<Number, Number>> seriesList = new ArrayList<>();
for (int i = 0; i < 15; i++) {
XYChart.Series<Number, Number> series = new XYChart.Series<>();
series.setName(i + "");
XYChart.Data<Number, Number> data = new XYChart.Data<>(Math.random(), Math.random());
int finalI = i;
data.nodeProperty().addListener(__ -> {
if (data.getNode() != null) {
data.getNode().getStyleClass().addListener((InvalidationListener) ___ -> {
System.out.println("[pre] Data #" + finalI + ": Style = " + data.getNode().getStyleClass());
// DIRTY-WORKAROUND-SOLUTION:
// (has to live in listener, otherwise the chart erases any custom styling on adding... :-/ )
String colorString = "default-color" + finalI % 8;
data.getNode().getStyleClass().removeIf(s -> s.startsWith("default-color") && !s.equals(colorString));
if (!data.getNode().getStyleClass().contains(colorString)) {
data.getNode().getStyleClass().add(colorString);
}
// --------------------------
System.out.println("[post] Data #" + finalI + ": Style = " + data.getNode().getStyleClass());
});
}
});
series.getData().add(data);
seriesList.add(series);
}
System.out.println("-----------------");
// What I tried:
// 1)
chart.getData().setAll(seriesList);
// 2)
// chart.dataProperty().setValue(FXCollections.observableArrayList(seriesList));
// 3)
// chart.getData().clear();
// 3a)
// chart.getData().addAll(seriesList);
// 3b)
// seriesList.forEach(series->chart.getData().add(series));
});
root.getChildren().add(chart);
root.getChildren().add(refresh);
primaryStage.setScene(new Scene(root));
primaryStage.show();
}
}
Java-Version jdk1.8.0_171 (x64)

What the easiest way to animate a Path as an object traverses it?

Consider a rectangle traversing a long, linear path. It would be useful to figure out where the shape had gone earlier in the animation. Displaying the entire path before the shape moves is not what I want. That is easily done by adding the path to the pane.
I want a trailing line behind the shape representing the path that the shape has traversed through so far. Does anyone know how to do this in Javafx? I am using Path and PathTransition to animate my object along a path.
There are various solutions. Depending on which one you choose decides your outcome.
You could use a Canvas and paint lines on it while a Node moves along the Path.
import javafx.animation.Animation;
import javafx.animation.PathTransition;
import javafx.application.Application;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.scene.Scene;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.layout.Pane;
import javafx.scene.paint.Color;
import javafx.scene.shape.Circle;
import javafx.scene.shape.CubicCurveTo;
import javafx.scene.shape.LineTo;
import javafx.scene.shape.MoveTo;
import javafx.scene.shape.Path;
import javafx.stage.Stage;
import javafx.util.Duration;
public class PathVisualization extends Application {
private static double SCENE_WIDTH = 400;
private static double SCENE_HEIGHT = 260;
Canvas canvas;
#Override
public void start(Stage primaryStage) throws Exception {
Pane root = new Pane();
Path path = createPath();
canvas = new Canvas(SCENE_WIDTH,SCENE_HEIGHT);
root.getChildren().addAll(path, canvas);
primaryStage.setScene(new Scene(root, SCENE_WIDTH, SCENE_HEIGHT));
primaryStage.show();
Animation animation = createPathAnimation(path, Duration.seconds(5));
animation.play();
}
private Path createPath() {
Path path = new Path();
path.setStroke(Color.RED);
path.setStrokeWidth(10);
path.getElements().addAll(new MoveTo(20, 20), new CubicCurveTo(380, 0, 380, 120, 200, 120), new CubicCurveTo(0, 120, 0, 240, 380, 240), new LineTo(20,20));
return path;
}
private Animation createPathAnimation(Path path, Duration duration) {
GraphicsContext gc = canvas.getGraphicsContext2D();
// move a node along a path. we want its position
Circle pen = new Circle(0, 0, 4);
// create path transition
PathTransition pathTransition = new PathTransition( duration, path, pen);
pathTransition.currentTimeProperty().addListener( new ChangeListener<Duration>() {
Location oldLocation = null;
/**
* Draw a line from the old location to the new location
*/
#Override
public void changed(ObservableValue<? extends Duration> observable, Duration oldValue, Duration newValue) {
// skip starting at 0/0
if( oldValue == Duration.ZERO)
return;
// get current location
double x = pen.getTranslateX();
double y = pen.getTranslateY();
// initialize the location
if( oldLocation == null) {
oldLocation = new Location();
oldLocation.x = x;
oldLocation.y = y;
return;
}
// draw line
gc.setStroke(Color.BLUE);
gc.setFill(Color.YELLOW);
gc.setLineWidth(4);
gc.strokeLine(oldLocation.x, oldLocation.y, x, y);
// update old location with current one
oldLocation.x = x;
oldLocation.y = y;
}
});
return pathTransition;
}
public static class Location {
double x;
double y;
}
public static void main(String[] args) {
launch(args);
}
}
Here's a screenshot how it looks like. Red is the actual path, Blue is the path that is drawn on the Canvas:
Other solutions use e. g. a clip. However, if you choose the same Duration as I did above (i. e. 5 seconds) with that technique, you'll get gaps like this:
The solution with the line drawing has its drawbacks as well. If you choose 1 second, you'll see the line segments. A possibiliy to circumvent this would be to smooth the path yourself. But for that you'd have to get into splitting the path into segments and that's a bit math-y.
Slightly offtopic, but how to paint along the mouse coordinates may also be interesing for you to give you ideas.
Michael Bostock does a path animation by manipulating the stroke dash array and interpolating the stroke dash offset. He provides an example (of course) which you can view here.
The same approach can be taken in JavaFX. Here is a DrawPathTransition
(Kotlin) class I created which uses this technique:
class DrawPathTransition(val path: Path) : Transition() {
private val DEFAULT_DURATION = Duration.millis(400.0)
private val length = path.totalLength
var duration: Duration
get() = durationProperty.get()
set(value) {
durationProperty.set(value)
}
val durationProperty = SimpleObjectProperty(DEFAULT_DURATION)
init {
durationProperty.addListener({ _ -> cycleDuration = duration })
statusProperty().addListener({ _, _, status ->
when(status) {
Status.RUNNING -> path.strokeDashArray.addAll(length, length)
Status.STOPPED -> path.strokeDashArray.clear()
}
})
}
override fun interpolate(frac: Double) {
path.strokeDashOffset = length - length * frac
}
}
The tricky part here is getting the path's total length. See my answer to this question for how that can be accomplished.
You can then combine a PathTransition with the above DrawPathTransition of the same duration in a ParallelTransition to get what you desire.
Since this approach modifies strokeDashArray and strokeDashOffset it only works with solid lines, but what if we want to support dashed lines as well? Nadieh Bremer has an excellent article about this which can be reviewed here.
The DrawPathTransition (Kotlin) class provided below implements this technique. Note that this can create a rather large strokeDashArray during the transition.
class DrawPathTransition(val path: Path) : Transition() {
private val length = path.totalLength
private val stroked = path.strokeDashArray.isNotEmpty()
private val dashArray: List<Double> = if(stroked) ArrayList(path.strokeDashArray) else emptyList()
private val dashLength = dashArray.sum()
private val dashOffset = path.strokeDashOffset
var duration: Duration
get() = durationProperty.get()
set(value) {
durationProperty.set(value)
}
val durationProperty = SimpleObjectProperty(DEFAULT_DURATION)
init {
durationProperty.addListener({ _ -> cycleDuration = duration })
if(stroked) {
val n = (length / dashLength).toInt()
path.strokeDashArray.clear()
(1..n).forEach { path.strokeDashArray.addAll(dashArray) }
path.strokeDashArray.addAll(0.0, length)
statusProperty().addListener({ _, _, status ->
if(status == Animation.Status.STOPPED) {
path.strokeDashOffset = dashOffset
path.strokeDashArray.setAll(dashArray)
}
})
}
}
override fun interpolate(frac: Double) {
path.strokeDashOffset = length - length * frac
}
}
I wasn't completely happy with this approach though, as the stroke appears to "march" along the path as the path is drawn, which doesn't look great particularly with short durations. Rather I wanted it to appear as if the stroke was being "revealed" over time (so no stroke movement). The DrawPathTransition (Kotlin) class below implements my solution:
class DrawPathTransition(val path: Path) : Transition() {
private val length = path.totalLength
private val stroked = path.strokeDashArray.isNotEmpty()
private val dashArray: List<Double> = if(stroked) ArrayList(path.strokeDashArray) else emptyList()
private val dashSum = dashArray.sum()
private val dashOffset = path.strokeDashOffset
var duration: Duration
get() = durationProperty.get()
set(value) {
durationProperty.set(value)
}
val durationProperty = SimpleObjectProperty(DEFAULT_DURATION)
init {
durationProperty.addListener({ _ -> cycleDuration = duration })
if(stroked) {
statusProperty().addListener({ _, _, status ->
if(status == Animation.Status.STOPPED) {
path.strokeDashOffset = dashOffset
path.strokeDashArray.setAll(dashArray)
}
})
}
}
override fun interpolate(frac: Double) {
val l = length * frac
if(stroked) {
path.strokeDashOffset = l
val n = ceil(l / dashSum).toInt()
path.strokeDashArray.clear()
path.strokeDashArray.addAll(0.0, l)
(1..n).forEach { path.strokeDashArray.addAll(dashArray) }
path.strokeDashArray.addAll(0.0, length - l)
}
else path.strokeDashOffset = length - l
}
}

Reflect Geometric Node around arbitrary axis in JavaFX

Is there a way in javafx to reflect/mirror a geometric node (line for example) around an arbitrary axis-- one that is user-defined?
I see rotation, scale, shear, and translation methods, but no reflect(mirror).
I stumpled upon a post that suggests using alignment Left to right , or Right to left, but it is not quit what I am looking for.
I didn't build the application yet to post sample code, as I am still looking for quite a few libraries before starting.
Any tips would be great from the community!
EDIT: Not sure how to add images to comments. I tried the code by James below, I get the result in the image below, but I am trying to get it where noted in read.
If you have a line segment defined by two points p1 and p2, then reflecting in that line segment is equivalent to
Translate p1 to the origin (T)
Rotate p2 to the positive x-axis (R)
Reflect in the x-axis (which you can do with a scale with x=1 and y=-1)
Inverse of R
Inverse of T
So this looks something like
private Transform createReflection(Point2D p1, Point2D p2) {
Translate translation = new Translate(-p1.getX(), -p1.getY());
double deltaX = p2.getX() - p1.getX();
double deltaY = p2.getY() - p1.getY();
Rotate rotate = new Rotate(-Math.toDegrees(Math.atan2(deltaY, deltaX)));
Scale scale = new Scale(1, -1);
Affine reflection = new Affine();
reflection.append(translation.createInverse());
try {
// not sure how a rotation could possibly be non-invertible:
reflection.append(rotate.createInverse());
} catch (NonInvertibleTransformException e) {
throw new RuntimeException(e);
}
reflection.append(scale);
reflection.append(rotate);
reflection.append(translation);
return reflection ;
}
SSCCE:
import javafx.application.Application;
import javafx.geometry.Point2D;
import javafx.scene.Group;
import javafx.scene.Scene;
import javafx.scene.layout.Pane;
import javafx.scene.shape.Line;
import javafx.scene.text.Text;
import javafx.scene.transform.Affine;
import javafx.scene.transform.NonInvertibleTransformException;
import javafx.scene.transform.Rotate;
import javafx.scene.transform.Scale;
import javafx.scene.transform.Transform;
import javafx.scene.transform.Translate;
import javafx.stage.Stage;
public class ArbitraryReflection extends Application {
#Override
public void start(Stage primaryStage) {
Text original = new Text("Through the looking glass");
original.relocate(200, 350);
Text reflected = new Text("Through the looking glass");
reflected.relocate(200, 350);
Group reflectedGp = new Group(reflected);
Point2D start = new Point2D(0, 400);
Point2D end = new Point2D(400, 150);
Line axis = new Line(start.getX(), start.getY(), end.getX(), end.getY());
Pane root = new Pane(original, reflectedGp, axis);
reflectedGp.getTransforms().add(createReflection(start, end));
Scene scene = new Scene(root, 400, 400);
primaryStage.setScene(scene);
primaryStage.show();
}
private Transform createReflection(Point2D p1, Point2D p2) {
Translate translation = new Translate(-p1.getX(), -p1.getY());
double deltaX = p2.getX() - p1.getX();
double deltaY = p2.getY() - p1.getY();
Rotate rotate = new Rotate(-Math.toDegrees(Math.atan2(deltaY, deltaX)));
Scale scale = new Scale(1, -1);
Affine reflection = new Affine();
reflection.append(translation.createInverse());
try {
// not sure how a rotation could possibly be non-invertible:
reflection.append(rotate.createInverse());
} catch (NonInvertibleTransformException e) {
throw new RuntimeException(e);
}
reflection.append(scale);
reflection.append(rotate);
reflection.append(translation);
return reflection ;
}
public static void main(String[] args) {
launch(args);
}
}

Resources