1

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.

  • 1
    This is a duplicate of [JavaFX path tracing animation](http://stackoverflow.com/questions/29302120/javafx-path-tracing-animation). I can't close it is a duplicate though because that question doesn't have an up-voted or accepted answer (even though the answer is really in the question). – jewelsea Feb 23 '16 at 19:18
  • @jewelsea: That technique may seem to work, but it's ugly. When the speed is too fast, you get gaps. And once the animation finishes the drawing line gets overwritten with the path which may contain peaks and what not. The better approach is to use a canvas imo. – Roland Feb 24 '16 at 04:39

2 Answers2

4

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:

enter image description here

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:

enter image description here

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.

Community
  • 1
  • 1
Roland
  • 18,114
  • 12
  • 62
  • 93
0

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
    }
}
hudsonb
  • 2,214
  • 19
  • 20