Wie kann man mit Graphics2D die Zeigerdrehung einer analogen Stoppuhr realisieren?

Am Beispiel einer analogen Stoppuhr lassen sich einige Möglichkeiten der Klasse Graphics2D und die grundlegende Steuerung eines Threads darstellen. Der Übersichtlichkeit halber ist es bewusst einfach gehalten worden und verzichtet sowohl auf Ziffern als auch Minuten- und Stundenzeiger. Deren Realisierung ist jedoch auf die gleiche Weise wie diejenige des Sekundenzeigers problemlos möglich.

Das Beispiel definiert zwei Klassen: Die Hauptklasse mit der main-Methode ist von JFrame abgeleitet und stellt den größten Teil des GUI in einem BorderLayout dar. Hier werden drei JButton zum Starten, Stoppen und Zurücksetzen der Uhr deklariert und beim ActionListener der Klasse angemeldet.
Über die Buttons wird in das Zentrum des Frames ein Objekt der Klasse UhrPanel gesetzt. Es beinhaltet die eigentliche Funktionalität. Um die zeitliche Steuerung zu ermöglichen, implementiert die von JPanel als Zeichenfläche abgeleitete Klasse das Interface Runnable. Die Klasse kann so als Thread ausgeführt werden. Dies geschieht bereits bei der Instanzierung der Klasse, indem in deren Konstruktor die Methode start() aufgerufen wird. Sie erzeugt ggf. den Thread und startet ihn.

import java.awt.BasicStroke;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.FlowLayout;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Shape;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.geom.AffineTransform;
import java.awt.geom.Line2D;

import javax.swing.JButton;
import javax.swing.JFrame;
import javax.swing.JPanel;

public class Stoppuhr extends JFrame implements ActionListener {

    private JButton startButt, stopButt, resetButt;
    private UhrPanel panel;

    public Stoppuhr() {
        panel = new UhrPanel();
        panel.setRunning(false);
        this.add(panel, BorderLayout.CENTER);

        startButt = new JButton("start");
        startButt.addActionListener(this);
        stopButt = new JButton("stop");
        stopButt.addActionListener(this);
        resetButt = new JButton("reset");
        resetButt.addActionListener(this);
        JPanel buttPanel = new JPanel(new FlowLayout());
        buttPanel.add(startButt);
        buttPanel.add(stopButt);
        buttPanel.add(resetButt);
        this.add(buttPanel, BorderLayout.SOUTH);

        this.setSize(300, 300);
        this.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        this.setVisible(true);
    }

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

    public void actionPerformed(ActionEvent e) {
        if (e.getSource() == startButt) {
            panel.setRunning(true);
        }
        if (e.getSource() == stopButt) {
            panel.setRunning(false);
        }
        if (e.getSource() == resetButt) {
            panel.setIndex(0);
            panel.setRunning(false);
            panel.repaint();
        }
    }
}

class UhrPanel extends JPanel implements Runnable {

    public UhrPanel() {
        start();
    }

    double winkel = Math.PI / -30;
    int index = 0;
    boolean running;
    private Thread thread;

    public void paintComponent(Graphics g) {
        Graphics2D g2d = (Graphics2D) g;
        g2d.setColor(Color.WHITE);
        g2d.fillRect(0, 0, this.getWidth(), this.getHeight());

        AffineTransform at = new AffineTransform();
        at.setToScale(1, -1);
        AffineTransform aff = new AffineTransform();
        aff.setToTranslation(this.getWidth() / 2, this.getHeight() / 2);
        at.preConcatenate(aff);
        g2d.transform(at);

        // Zifferzeichen
        g2d.setColor(Color.RED);
        Line2D.Double ziffer = new Line2D.Double(0, 60, 0, 70);
        Shape zShape;
        for (int i = 0; i < 61; i += 5) {
            at.setToRotation(winkel * i);
            zShape = at.createTransformedShape(ziffer);
            g2d.draw(zShape);
        }

        // Zifferzeichen an den Positionen 12/3/6/9
        g2d.setColor(Color.BLACK);
        g2d.setStroke(new BasicStroke(3));
        Line2D.Double viertel = new Line2D.Double(0, 60, 0, 70);
        Shape qShape;
        for (int i = 0; i < 61; i += 15) {
            at.setToRotation(winkel * i);
            qShape = at.createTransformedShape(viertel);
            g2d.draw(qShape);
        }

        // Zeiger
        g2d.setColor(Color.BLACK);
        Line2D.Double line = new Line2D.Double(0, 0, 0, 50);
        at.setToRotation(winkel * index, 0, 0);
        Shape s = at.createTransformedShape(line);
        g2d.draw(s);

    }

    public void start() {
        if (thread == null) {
            thread = new Thread(this);
            thread.start();
        }
    }

    public void setRunning(boolean running) {
        this.running = running;
    }

    public void setIndex(int i) {
        this.index = i;
    }

    public void run() {
        while (true) {
            if (running) {
                index++;
                index = index > 60 ? 1 : index;
                repaint();
            }
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

Die Methode run() des Threads führt eine Endlosschleife aus, in der die boolsche Variable running abgefragt wird. Ist sie true wird das Feld index incrementiert. Es kann Werte zwischen 1 und 60 annehmen und steuert den Winkel des Sekundenzeigers. Dies wird erreicht, indem mittels repaint() die Methode paintComponent() wiederholt aufgerufen wird. Sie dient dazu, das Panel neu zu zeichnen und so u.a. auch index jedes Mal neu auszuwerten. Thread.sleep() bewirkt die Unterbrechung der Threadausführung innerhalb der kontinuierlich ablaufenden while-Schleife für 1000 Millisekunden. Jede Sekunde wird somit index hochgezählt und die Komponente mit diesem Wert neu gezeichnet.
Schauen wir uns paintComponent(Graphics g) etwas genauer an. Das übergebene Graphics-Objekt wird als erstes in ein Graphics2D-Objekt gecastet. Auf diesem werden die nachfolgenden Operationen ausgeführt.
Nach dem Überzeichnen mit einer weißen Grundfläche wird als erstes die Orientierung der Y-Achse des der Zeichnung zugrunde liegenden Koordinatensystems umgekehrt. Im Normalfalle verläuft sie von oben nach unten, sodass in der linken oberen Ecke des Zeichnungsfensters der Punkt 0|0 liegt und üblicherweise von oben nach unten gezeichnet wird. Obwohl es natürlich auch so ginge, soll der 0-Punkt hier in die Mitte der Zeichenfläche gelegt und von unten nach oben gezeichnet werden. Um das zu erreichen wird eine affine Transformation durchgeführt, die durch die gleichnamige Klasse realisiert werden kann. Ihre Methode setToScale() führt hier eine Skalierung mit dem Faktor 1 (also keine) durch, deren Y-Wert jedoch negativiert wird. Ein weiteres AffineTransform-Objekt mit einer Verschiebung des Initialisierungspunktes in die Mitte des Panels wird erzeugt und mit dem ersten verknüpft. Das Graphics2D-Objekt wird dann mit diesem Initialisierungspunkt versehen.
Die drei dann folgenden Blöcke sind recht ähnlich.

Im letzten Block findet sich das oben erwähnte Feld index wieder, das den Wnkel winkel multipliziert und den Sekundenzeiger um diesen um den Punkt 0|0 rotieren lässt.