Analog Clock Revisited

Screenshot of the analog clockI have been playing around with JavaFX on and off since it came out and I got really inspired by Per’s blog post about the analog clock. So I decided to spend my time at the Crisp Hack Summit trying to improve Per’s design both in code and visually.

The first thing I did was to make the hands move smooth like a Rolex. I started out by adding a millisecond property to the clockwork and increased the number of “ticks” per second in the timeline. It turned out that the code for calculating the exact angle of each hand based on the current time got a bit ugly and intertwined with the code that drew the hands. Hmmm… I thought… what about some separation of concerns here… and what does a real analog clockwork do? I realised that the hands on a real clock are stupid, attached to a smart clockwork that knows the angles for each hand. So I moved all code that calculate angles to the clockwork class and called it AnalogClockwork. It now exposes three properties, the angles for each hand which is the only thing the hands need to know. The clockwork is of course also resposible for the “ticking” which means knowing the time and updating the angles of each hand.

Here is what the code looks like that updates the angles of a hand in the clockwork:


private void updateSecondsHandAngle() {
    secondsHandAngle.set(currentSecondsWithFractions() * DEGREES_PER_SECOND);
}

private double currentSecondsWithFractions() {
    Calendar calendar = Calendar.getInstance();
    double currentSeconds = calendar.get(Calendar.SECOND);
    double currentMilliseconds = calendar.get(Calendar.MILLISECOND);
    return currentSeconds + currentMilliseconds / 1000d;
}

One of the great pleasures of working with JavaFX is that it’s really easy to separate concerns and glue things together with property bindings. That means the “GUI-code” can be purely focused on creating beautiful graphical representations of values that are calculated and updated elsewhere.

Here is the code that binds the angle property to the hand:


private Node hourHand() {
    double distanceFromRim = START_RADIUS * 0.5;
    Rotate rotate = handRotation(clockwork.hourHandAngle());
    return hourOrMinuteHand(distanceFromRim, Color.BLACK, rotate);
}

private Rotate handRotation(ObservableDoubleValue handAngle) {
   Rotate handRotation = RotateBuilder.create()
       .pivotX(START_RADIUS)
       .pivotY(START_RADIUS)
       .build();
   handRotation.angleProperty().bind(handAngle);
   return handRotation;
}

I then modified the graphical design to make the clock look more like a swiss railway clock. http://en.wikipedia.org/wiki/Swiss_railway_clock

Below is the entire source, enjoy!
/Oscar

package se.crisp.clock;

import javafx.application.Application;
import javafx.beans.property.SimpleDoubleProperty;
import javafx.beans.value.ObservableDoubleValue;
import javafx.event.EventHandler;
import javafx.scene.*;
import javafx.scene.input.MouseEvent;
import javafx.scene.input.ScrollEvent;
import javafx.scene.paint.*;
import javafx.scene.shape.*;
import javafx.scene.transform.Rotate;
import javafx.scene.transform.RotateBuilder;
import javafx.stage.Stage;
import javafx.stage.StageStyle;

public class AnalogClock extends Application {

    private static final double START_RADIUS = 100;
    private static final int NO_HOUR_TICKS = 12;
    private static final int NO_MINUTE_TICKS = 60;
    private final AnalogClockwork clockwork = new AnalogClockwork();

    public static void main(String[] args) {
        launch(args);
    }

    public void start(final Stage stage) throws Exception {
        final Parent root = GroupBuilder.create()
            .children(
                clockDial(),
                minuteTickMarks(),
                hourTickMarks(),
                hourHand(),
                minuteHand(),
                secondsHand()
             )
             .build();
        setUpMouseForScaleAndMove(stage, root);
        Scene scene = transparentScene(root);
        showTransparentStage(stage, scene);
    }

    private Node clockDial() {

        Stop stops[] = {
            new Stop(0.92, Color.WHITE),
            new Stop(0.98, Color.BLACK),
            new Stop(1.0, Color.BLACK)
        };
        RadialGradient gradient = new RadialGradient(0, 0, 0.5, 0.5, 0.5, true, CycleMethod.NO_CYCLE, stops);

        Circle circle = new Circle(START_RADIUS, gradient);
        circle.setCenterX(START_RADIUS);
        circle.setCenterY(START_RADIUS);
        return circle;
    }

    private Node hourHand() {
        double distanceFromRim = START_RADIUS * 0.5;
        Rotate rotate = handRotation(clockwork.hourHandAngle());
        return hourOrMinuteHand(distanceFromRim, Color.BLACK, rotate);
    }

    private Node minuteHand() {
        double distanceFromRim = START_RADIUS * 0.75;
        Rotate rotate = handRotation(clockwork.minuteHandAngle());
        return hourOrMinuteHand(distanceFromRim, Color.BLACK, rotate);
    }

    private Node secondsHand() {
        double distanceFromRim = START_RADIUS * 0.7;
        Color handColor = Color.RED;
        Rotate rotate = handRotation(clockwork.secondsHandAngle());
        return GroupBuilder.create()
            .children(
                secondsHandLine(distanceFromRim, handColor),
                secondsHandTip(distanceFromRim, handColor),
                centerPoint(handColor)
            )
           .transforms(rotate)
           .build();
    }

    private Node secondsHandTip(double distanceFromRim, Color handColor) {
        double handTipRadius = START_RADIUS * 0.07;
        return CircleBuilder.create()
            .centerX(START_RADIUS)
            .centerY(START_RADIUS - distanceFromRim)
            .fill(handColor)
            .radius(handTipRadius)
            .build();
    }

    private Node secondsHandLine(double distanceFromRim, Paint handColor) {
        double handCenterExtension = START_RADIUS * 0.15;
        double handWidth = START_RADIUS * 0.02;
        return LineBuilder.create()
            .startX(START_RADIUS)
            .startY(START_RADIUS - distanceFromRim)
            .endX(START_RADIUS)
            .endY(START_RADIUS + handCenterExtension)
            .strokeWidth(handWidth)
            .stroke(handColor)
            .build();
    }

    private Rotate handRotation(ObservableDoubleValue handAngle) {
        Rotate handRotation = RotateBuilder.create()
            .pivotX(START_RADIUS)
            .pivotY(START_RADIUS)
            .build();
        handRotation.angleProperty().bind(handAngle);
        return handRotation;
    }

    private Node hourOrMinuteHand(double distanceFromRim, Color color, Rotate rotate) {
        double handBaseWidth = START_RADIUS * 0.05;
        double handTipWidth = START_RADIUS * 0.03;
        double handCenterExtension = START_RADIUS * 0.15;
        double leftBaseCornerX = START_RADIUS - handBaseWidth;
        double baseY = START_RADIUS + handCenterExtension;
        double tipY = START_RADIUS - distanceFromRim;
        double leftTipCornerX = START_RADIUS - handTipWidth;
        double rightTipCornerX = START_RADIUS + handTipWidth;
        double rightCornerBaseX = START_RADIUS + handBaseWidth;
        return PathBuilder.create()
            .fill(color)
            .stroke(Color.TRANSPARENT)
            .elements(
                new MoveTo(leftBaseCornerX, baseY),
                new LineTo(leftTipCornerX, tipY),
                new LineTo(rightTipCornerX, tipY),
                new LineTo(rightCornerBaseX, baseY),
                new LineTo(leftBaseCornerX, baseY)
             )
             .transforms(rotate)
             .build();
    }

    private Node minuteTickMarks() {
        Group tickMarkGroup = new Group();
        int noTicks = NO_MINUTE_TICKS;
        for (int n = 0; n < noTicks; n++) {
            tickMarkGroup.getChildren().add(tickMark(n, 1, noTicks));
        }
        return tickMarkGroup;
    }

    private Node hourTickMarks() {
        Group tickMarkGroup = new Group();
        int noTicks = NO_HOUR_TICKS;
        for (int n = 0; n < noTicks; n++) {
            tickMarkGroup.getChildren().add(tickMark(n, 6, noTicks));
        }
        return tickMarkGroup;
    }

    private Node tickMark(int n, double width, int noTicks) {
        return LineBuilder.create()
            .startX(START_RADIUS)
            .startY(START_RADIUS * 0.12)
            .endX(START_RADIUS)
            .endY(START_RADIUS * 0.2 + width * 2)
            .transforms(
                RotateBuilder.create()
                .pivotX(START_RADIUS)
                .pivotY(START_RADIUS)
                .angle(360 / noTicks * n)
                .build()
            )
           .strokeWidth(width)
           .build();
    }

    private Node centerPoint(Color color) {
        return CircleBuilder.create()
            .fill(color)
            .radius(0.03 * START_RADIUS)
            .centerX(START_RADIUS)
            .centerY(START_RADIUS)
            .build();
    }

    private void setUpMouseForScaleAndMove(final Stage stage, final Parent root) {
        SimpleDoubleProperty mouseStartX = new SimpleDoubleProperty(0);
        SimpleDoubleProperty mouseStartY = new SimpleDoubleProperty(0);
        root.setOnMousePressed(setMouseStartPoint(mouseStartX, mouseStartY));
        root.setOnMouseDragged(moveWhenDragging(stage, mouseStartX, mouseStartY));
        root.onScrollProperty().set(scaleWhenScrolling(stage, root));
    }

    private EventHandler<? super MouseEvent> setMouseStartPoint(final SimpleDoubleProperty mouseStartX, final SimpleDoubleProperty mouseStartY) {
        return new EventHandler<MouseEvent>() {
            public void handle(MouseEvent mouseEvent) {
                mouseStartX.set(mouseEvent.getX());
                mouseStartY.set(mouseEvent.getY());
            }
        };
    }

    private EventHandler<MouseEvent> moveWhenDragging(final Stage stage, final SimpleDoubleProperty mouseStartX, final SimpleDoubleProperty mouseStartY) {
        return new EventHandler<MouseEvent>() {
            public void handle(MouseEvent mouseEvent) {
                stage.setX(stage.getX() + mouseEvent.getX() - mouseStartX.doubleValue());
                stage.setY(stage.getY() + mouseEvent.getY() - mouseStartY.doubleValue());
            }
        };
    }

    private EventHandler<ScrollEvent> scaleWhenScrolling(final Stage stage, final Parent root) {
        return new EventHandler<ScrollEvent>() {
            public void handle(ScrollEvent scrollEvent) {
                double scroll = scrollEvent.getDeltaY();
                root.setScaleX(root.getScaleX() + scroll / 100);
                root.setScaleY(root.getScaleY() + scroll / 100);
                root.setTranslateX(root.getTranslateX() + scroll);
                root.setTranslateY(root.getTranslateY() + scroll);
                stage.sizeToScene();
            }
        };
    }

    private Scene transparentScene(Parent root) {
        return SceneBuilder.create()
            .root(root)
            .fill(Color.TRANSPARENT)
            .build();
    }

    private void showTransparentStage(Stage stage, Scene scene) {
        stage.setScene(scene);
        stage.initStyle(StageStyle.TRANSPARENT);
        stage.show();
    }

}

package se.crisp.clock;

import javafx.animation.KeyFrame;
import javafx.animation.Timeline;
import javafx.animation.TimelineBuilder;
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.SimpleDoubleProperty;
import javafx.event.ActionEvent;
import javafx.event.EventHandler;
import javafx.util.Duration;

import java.util.Calendar;

public class AnalogClockwork {

    private static final double HOURS_ON_CLOCK = 12d;
    private static final double SECONDS_ON_CLOCK = 60d;
    private static final double MINUTES_ON_CLOCK = 60d;
    private static final double DEGREES_PER_SECOND = 360d / SECONDS_ON_CLOCK;
    private static final double DEGREES_PER_MINUTE = 360d / MINUTES_ON_CLOCK;
    private static final double DEGREES_PER_HOUR = 360d / HOURS_ON_CLOCK;
    private static final Duration SECONDS_HAND_TICK = Duration.millis(50);
    private static final Duration MINUTE_HAND_TICK = Duration.millis(500);
    private static final Duration HOUR_HAND_TICK = Duration.seconds(10);

    private SimpleDoubleProperty hourHandAngle = new SimpleDoubleProperty(0);
    private SimpleDoubleProperty minuteHandAngle = new SimpleDoubleProperty(0);
    private SimpleDoubleProperty secondsHandAngle = new SimpleDoubleProperty(0);

    public AnalogClockwork() {
        updateHandAngles();
        startTicking();
    }

    public DoubleProperty hourHandAngle() {
        return hourHandAngle;
    }

    public DoubleProperty minuteHandAngle() {
        return minuteHandAngle;
    }

    public DoubleProperty secondsHandAngle() {
        return secondsHandAngle;
    }

    private void updateHandAngles() {
        updateSecondsHandAngle();
        updateMinuteHandAngle();
        updateHourHandAngle();
    }

    private void startTicking() {
        startHandTicking(SECONDS_HAND_TICK, new EventHandler<ActionEvent>() {
            public void handle(ActionEvent actionEvent) {
                updateSecondsHandAngle();
            }
        });
        startHandTicking(MINUTE_HAND_TICK, new EventHandler<ActionEvent>() {
            public void handle(ActionEvent actionEvent) {
                updateMinuteHandAngle();
            }
        });
        startHandTicking(HOUR_HAND_TICK, new EventHandler<ActionEvent>() {
             public void handle(ActionEvent actionEvent) {
                 updateHourHandAngle();
             }
        });
    }

    private void startHandTicking(Duration tickDuration, EventHandler<ActionEvent> onTick) {
        TimelineBuilder.create()
            .cycleCount(Timeline.INDEFINITE)
            .keyFrames(new KeyFrame(tickDuration, onTick))
            .build()
            .play();
    }

    private void updateHourHandAngle() {
        hourHandAngle.set(currentHourWithFractions() * DEGREES_PER_HOUR);
    }

    private void updateMinuteHandAngle() {
        minuteHandAngle.set(currentMinuteWithFractions() * DEGREES_PER_MINUTE);
    }

    private void updateSecondsHandAngle() {
        secondsHandAngle.set(currentSecondsWithFractions() * DEGREES_PER_SECOND);
    }

    private double currentHourWithFractions() {
        double hours = (double) Calendar.getInstance().get(Calendar.HOUR);
        return hours + currentMinuteWithFractions() / MINUTES_ON_CLOCK;
    }

    private double currentMinuteWithFractions() {
        double minutes = (double) Calendar.getInstance().get(Calendar.MINUTE);
        return minutes + currentSecondsWithFractions() / SECONDS_ON_CLOCK;
    }

    private double currentSecondsWithFractions() {
        Calendar calendar = Calendar.getInstance();
        double currentSeconds = calendar.get(Calendar.SECOND);
        double currentMilliseconds = calendar.get(Calendar.MILLISECOND);
        return currentSeconds + currentMilliseconds / 1000d;
    }
}

18 responses on “Analog Clock Revisited

  1. Thanks, Great article! Very clean piece of writing. Source code works great! Great tutorial for all the java lovers.

  2. There seems to be a problem when dragging the clock. If you only drag it a little bit, it is fine. However, when you drag it a lot, it jumps around like crazy and then disppears.
    I have output the X location, and you can see how the delta X goes crazy.

    Any idea???

    stage.getX()=123.0 deltaX=2.0
    stage.getX()=131.0 deltaX=8.0
    stage.getX()=134.0 deltaX=3.0
    stage.getX()=137.0 deltaX=3.0
    stage.getX()=141.0 deltaX=4.0
    stage.getX()=146.0 deltaX=5.0
    stage.getX()=136.0 deltaX=-10.0
    stage.getX()=127.0 deltaX=-9.0
    stage.getX()=138.0 deltaX=11.0
    stage.getX()=150.0 deltaX=12.0
    stage.getX()=163.0 deltaX=13.0
    stage.getX()=140.0 deltaX=-23.0
    stage.getX()=117.0 deltaX=-23.0
    stage.getX()=95.0 deltaX=-22.0
    stage.getX()=141.0 deltaX=46.0
    stage.getX()=189.0 deltaX=48.0
    stage.getX()=238.0 deltaX=49.0
    stage.getX()=144.0 deltaX=-94.0
    stage.getX()=51.0 deltaX=-93.0
    stage.getX()=-40.0 deltaX=-91.0
    stage.getX()=147.0 deltaX=187.0
    stage.getX()=335.0 deltaX=188.0
    stage.getX()=525.0 deltaX=190.0
    stage.getX()=150.0 deltaX=-375.0
    stage.getX()=-224.0 deltaX=-374.0
    stage.getX()=-596.0 deltaX=-372.0
    stage.getX()=153.0 deltaX=749.0
    stage.getX()=905.0 deltaX=752.0
    stage.getX()=156.0 deltaX=-749.0
    stage.getX()=-592.0 deltaX=-748.0
    stage.getX()=-1339.0 deltaX=-747.0
    stage.getX()=158.0 deltaX=1497.0
    stage.getX()=1656.0 deltaX=1498.0
    stage.getX()=3155.0 deltaX=1499.0
    stage.getX()=160.0 deltaX=-2995.0
    stage.getX()=-2834.0 deltaX=-2994.0
    stage.getX()=-5827.0 deltaX=-2993.0
    stage.getX()=162.0 deltaX=5989.0
    stage.getX()=6152.0 deltaX=5990.0
    stage.getX()=12143.0 deltaX=5991.0
    stage.getX()=164.0 deltaX=-11979.0
    stage.getX()=-11814.0 deltaX=-11978.0

  3. hi, i’m john snf i’m newbie in javafx programming. i have several querstion that i don’t
    understand from your code,

    first :
    there is two source code, what shouls i do? i’m really beginer in javafx programming, should i to create new package or new class or what? i’m currently use NET BEANS 7.2??

    second :
    i choose new class than I copied your code into my “JAVAFX application project” (is that right ?) than i have 2 file first is analogclock.java and the second one is analogclockwork.java than i got 3 error for each file,

    a. in analogclock.java i found error in this code
    – clockwork.hourHandAngle()
    – clockwork.minuteHandAngle()
    – clockwork.minuteHandAngle()

    error -> “Cannot find symbol”

    error detail :
    ant -f C:\\Users\\asus\\Documents\\NetBeansProjects\\JavaFXApplication5 -Djavac.includes=javafxapplication5/AnalogClock.java compile-single
    init:
    Deleting: C:\Users\asus\Documents\NetBeansProjects\JavaFXApplication5\build\built-jar.properties
    deps-jar:
    Updating property file: C:\Users\asus\Documents\NetBeansProjects\JavaFXApplication5\build\built-jar.properties
    Compiling 1 source file to C:\Users\asus\Documents\NetBeansProjects\JavaFXApplication5\build\classes
    C:\Users\asus\Documents\NetBeansProjects\JavaFXApplication5\src\javafxapplication5\AnalogClock.java:61: cannot find symbol
    symbol : method hourHandAngle()
    location: class javafxapplication5.AnalogClock
    Rotate rotate = handRotation(clockwork.hourHandAngle());
    C:\Users\asus\Documents\NetBeansProjects\JavaFXApplication5\src\javafxapplication5\AnalogClock.java:67: cannot find symbol
    symbol : method minuteHandAngle()
    location: class javafxapplication5.AnalogClock
    Rotate rotate = handRotation(clockwork.minuteHandAngle());
    C:\Users\asus\Documents\NetBeansProjects\JavaFXApplication5\src\javafxapplication5\AnalogClock.java:74: cannot find symbol
    symbol : method secondsHandAngle()
    location: class javafxapplication5.AnalogClock
    Rotate rotate = handRotation(clockwork.secondsHandAngle());
    3 errors
    C:\Users\asus\Documents\NetBeansProjects\JavaFXApplication5\nbproject\build-impl.xml:972: The following error occurred while executing this line:
    C:\Users\asus\Documents\NetBeansProjects\JavaFXApplication5\nbproject\build-impl.xml:297: Compile failed; see the compiler error output for details.
    BUILD FAILED (total time: 1 second)

    can you help me? i really need your help for my colage work, thanks

    1. Hi John, thanks for your interest. Let me try to answer your questions:

      Yes, there is source code for two classes. These go into two separate files, named AnalogClock.java and AnalogClockwork.java.

      If you got the files correct they should compile. If you need to learn more basics you could for instance have a look at the Java Tutorial.

  4. sorry, i got error just for one file, in analogclock.java after i’m compiling,

    thanks for your help, i’m really appreciate it,

      1. in projects properties i got this structure :
        – AnalogClock
        – Source Packages
        – se.crisp.clock (with exclamation mark)
        – AnalogClock.java (with exclamation mark)
        – AnalogClockwork.java
        -libraries
        – Default JavaFx Platform

        i have just 3 errors in AnalogClock.java, there is :
        – clockwork.hourHandAngle()
        – clockwork.minuteHandAngle()
        – clockwork.minuteHandAngle()

        one of error said :
        “Cannot find symbol, symbol : method minuteHandAngle()
        location: variable clockwork of type AnalogClock
        —-”

        thanks again for your help.

        1. Hmmm… the error sounds strange. My suggestion is to make sure that the class AnalogClockwork is compiling and then make sure that the variable clockwork in AnalogClock is of type AnalogClockwork.

          Hope you will have it running soon.

  5. This might be a little late, but to fix the dragging issue posted by Jim Kay, change the following two lines:

    stage.setX(stage.getX() + mouseEvent.getX() – mouseStartX.doubleValue());
    stage.setY(stage.getY() + mouseEvent.getY() – mouseStartY.doubleValue());

    to:

    stage.setX(stage.getX() + (mouseEvent.getX() – mouseStartX.doubleValue())/2);
    stage.setY(stage.getY() + (mouseEvent.getY() – mouseStartY.doubleValue())/2);

    in the moveWhenDragging eventHandler method

  6. Hi Oscar,

    Your clock is very nice, and the code is very elegant. Can I use this code in my own application ?

    Bryan

    1. Thanks for asking Bryan. Yes please use the code and modify it as you wish and if you like please make a mention of where you got it from.

      Oscar

  7. Hi OScar,
    Amazing work thanks a lot.

    Actually I want to use this clock in scene builder can you please tell me how can i use it in scene builder?

    Also do you have any implementation in which If I change my clock In Spinner my analog clock will change ?

    I actually want to set clock from my UI.

    Thanks in advance.
    Regards,
    Ajayu

    1. Thanks Ajayu! Excellent questions. Unfortunatly I have limited exeprience with scene builder so you will have to figure out by yourself how to use it in scene builder. You will also have to solve the second question yourself. As I mentioned in another reply you are free to use the code and modify it as you wish and if you like you can make a mention of where you got the original code.

      Regards, Oscar

Leave a Reply to ajay Cancel reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.