001package jmri.jmrit.display;
002
003import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
004
005import java.awt.Color;
006import java.awt.Font;
007import java.awt.FontMetrics;
008import java.awt.Graphics;
009import java.awt.Image;
010import java.awt.Polygon;
011import java.awt.event.ActionEvent;
012import java.awt.event.ActionListener;
013import java.awt.geom.AffineTransform;
014import java.util.Date;
015
016import javax.swing.ButtonGroup;
017import javax.swing.JMenu;
018import javax.swing.JMenuItem;
019import javax.swing.JPopupMenu;
020import javax.swing.JRadioButtonMenuItem;
021
022import jmri.*;
023import jmri.jmrit.catalog.NamedIcon;
024import jmri.util.swing.JmriColorChooser;
025import jmri.util.swing.JmriMouseEvent;
026
027import org.slf4j.Logger;
028import org.slf4j.LoggerFactory;
029
030/**
031 * An Analog Clock for displaying in a panel.
032 * <p>
033 * Time code copied in part from code for the Nixie clock by Bob Jacobsen
034 *
035 * @author Howard G. Penny - Copyright (C) 2005
036 */
037public class AnalogClock2Display extends PositionableJComponent implements LinkingObject {
038
039    Timebase clock;
040    double rate;
041    double minuteAngle;
042    double hourAngle;
043    String amPm;
044    Color color = Color.black;
045
046    // Define common variables
047    Image logo;
048    Image scaledLogo;
049    Image clockFace;
050    NamedIcon jmriIcon;
051    NamedIcon scaledIcon;
052    NamedIcon clockIcon;
053
054    int[] hourX = {
055        -12, -11, -25, -10, -10, 0, 10, 10, 25, 11, 12};
056    int[] hourY = {
057        -31, -163, -170, -211, -276, -285, -276, -211, -170, -163, -31};
058    int[] minuteX = {
059        -12, -11, -24, -11, -11, 0, 11, 11, 24, 11, 12};
060    int[] minuteY = {
061        -31, -261, -266, -314, -381, -391, -381, -314, -266, -261, -31};
062    int[] scaledHourX = new int[hourX.length];
063    int[] scaledHourY = new int[hourY.length];
064    int[] scaledMinuteX = new int[minuteX.length];
065    int[] scaledMinuteY = new int[minuteY.length];
066    int[] rotatedHourX = new int[hourX.length];
067    int[] rotatedHourY = new int[hourY.length];
068    int[] rotatedMinuteX = new int[minuteX.length];
069    int[] rotatedMinuteY = new int[minuteY.length];
070
071    Polygon hourHand;
072    Polygon scaledHourHand;
073    Polygon minuteHand;
074    Polygon scaledMinuteHand;
075    int minuteHeight;
076    int hourHeight;
077    double scaleRatio;
078    int faceSize;
079    int panelWidth;
080    int panelHeight;
081    int size;
082    int logoWidth;
083    int logoHeight;
084
085    // centreX, centreY are the coordinates of the centre of the clock
086    int centreX;
087    int centreY;
088
089    String _url;
090
091    public AnalogClock2Display(Editor editor) {
092        super(editor);
093        clock = InstanceManager.getDefault(jmri.Timebase.class);
094
095        rate = (int) clock.userGetRate();
096
097        init();
098    }
099
100    public AnalogClock2Display(Editor editor, String url) {
101        this(editor);
102        _url = url;
103    }
104
105    @Override
106    public Positionable deepClone() {
107        AnalogClock2Display pos;
108        if (_url == null || _url.trim().length() == 0) {
109            pos = new AnalogClock2Display(_editor);
110        } else {
111            pos = new AnalogClock2Display(_editor, _url);
112        }
113        return finishClone(pos);
114    }
115
116    protected Positionable finishClone(AnalogClock2Display pos) {
117        pos.setScale(getScale());
118        return super.finishClone(pos);
119    }
120
121    final void init() {
122        // Load the JMRI logo and clock face
123        // Icons are the original size version kept for to allow for mulitple resizing
124        // and scaled Icons are the version scaled for the panel size
125        jmriIcon = new NamedIcon("resources/logo.gif", "resources/logo.gif");
126        scaledIcon = new NamedIcon("resources/logo.gif", "resources/logo.gif");
127        clockIcon = new NamedIcon("resources/clock2.gif", "resources/clock2.gif");
128        logo = jmriIcon.getImage();
129        clockFace = clockIcon.getImage();
130
131        // Create an unscaled set of hands to get the original size (height)to use
132        // in the scaling calculations
133        hourHand = new Polygon(hourX, hourY, 11);
134        hourHeight = hourHand.getBounds().getSize().height;
135        minuteHand = new Polygon(minuteX, minuteY, 11);
136        minuteHeight = minuteHand.getBounds().getSize().height;
137
138        amPm = "AM";
139
140        // request callback to update time
141        clock.addMinuteChangeListener(e -> update());
142        // request callback to update changes in properties
143        clock.addPropertyChangeListener(e -> update());
144        setSize(clockIcon.getIconHeight()); // set to default size
145    }
146
147    ButtonGroup colorButtonGroup = null;
148    ButtonGroup rateButtonGroup = null;
149    JMenuItem runMenu = null;
150
151    public int getFaceWidth() {
152        return faceSize;
153    }
154
155    public int getFaceHeight() {
156        return faceSize;
157    }
158
159    @Override
160    public boolean setScaleMenu(JPopupMenu popup) {
161
162        popup.add(new JMenuItem(Bundle.getMessage("FastClock")));
163        JMenu rateMenu = new JMenu("Clock rate");
164        rateButtonGroup = new ButtonGroup();
165        addRateMenuEntry(rateMenu, 1);
166        addRateMenuEntry(rateMenu, 2);
167        addRateMenuEntry(rateMenu, 4);
168        addRateMenuEntry(rateMenu, 8);
169        popup.add(rateMenu);
170        runMenu = new JMenuItem(getRun() ? "Stop" : "Start");
171        runMenu.addActionListener(e -> {
172            setRun(!getRun());
173            update();
174        });
175        popup.add(runMenu);
176        popup.add(CoordinateEdit.getScaleEditAction(this));
177        popup.addSeparator();
178        JMenuItem colorMenuItem = new JMenuItem(Bundle.getMessage("Color"));
179        colorMenuItem.addActionListener((ActionEvent event) -> {
180            Color desiredColor = JmriColorChooser.showDialog(this,
181                                 Bundle.getMessage("DefaultTextColor", ""),
182                                 color);
183            if (desiredColor!=null && !color.equals(desiredColor)) {
184               setColor(desiredColor);
185           }
186        });
187        popup.add(colorMenuItem);
188
189        return true;
190    }
191
192    @Override
193    public String getNameString() {
194        return "Clock";
195    }
196
197    @Override
198    public void setScale(double scale) {
199        if (scale == 1.0) {
200            init();
201            return;
202        }
203        AffineTransform t = AffineTransform.getScaleInstance(scale, scale);
204        clockIcon = new NamedIcon("resources/clock2.gif", "resources/clock2.gif");
205        int w = (int) Math.ceil(scale * clockIcon.getIconWidth());
206        int h = (int) Math.ceil(scale * clockIcon.getIconHeight());
207        clockIcon.transformImage(w, h, t, null);
208        scaledIcon = new NamedIcon("resources/logo.gif", "resources/logo.gif");
209        w = (int) Math.ceil(scale * scaledIcon.getIconWidth());
210        h = (int) Math.ceil(scale * scaledIcon.getIconHeight());
211        scaledIcon.transformImage(w, h, t, null);
212        jmriIcon = new NamedIcon("resources/logo.gif", "resources/logo.gif");
213        w = (int) Math.ceil(scale * jmriIcon.getIconWidth());
214        h = (int) Math.ceil(scale * jmriIcon.getIconHeight());
215        jmriIcon.transformImage(w, h, t, null);
216        logo = jmriIcon.getImage();
217        clockFace = clockIcon.getImage();
218        setSize(clockIcon.getIconHeight());
219        super.setScale(scale);
220    }
221
222    @SuppressFBWarnings(value="FE_FLOATING_POINT_EQUALITY", justification="fixed number of possible values")
223    void addRateMenuEntry(JMenu menu, final int newrate) {
224        JRadioButtonMenuItem button = new JRadioButtonMenuItem("" + newrate + ":1");
225        button.addActionListener(new ActionListener() {
226            final int r = newrate;
227
228            @Override
229            public void actionPerformed(ActionEvent e) {
230                try {
231                    clock.userSetRate(r);
232                    rate = r;
233                } catch (TimebaseRateException t) {
234                    log.error("TimebaseRateException for rate= {}", r, t);
235                }
236            }
237        });
238        rateButtonGroup.add(button);
239
240        // next line is the FE_FLOATING_POINT_EQUALITY annotated above
241        if (rate == newrate) {
242            button.setSelected(true);
243        } else {
244            button.setSelected(false);
245        }
246        menu.add(button);
247    }
248
249    public Color getColor() {
250        return this.color;
251    }
252
253    public void setColor(Color color) {
254        this.color = color;
255        update();
256        JmriColorChooser.addRecentColor(color);
257    }
258
259    @Override
260    public void paint(Graphics g) {
261        // overridden Paint method to draw the clock
262        g.setColor(color);
263        g.translate(centreX, centreY);
264
265        // Draw the clock face
266        g.drawImage(clockFace, -faceSize / 2, -faceSize / 2, faceSize, faceSize, this);
267
268        // Draw the JMRI logo
269        g.drawImage(scaledLogo, -logoWidth / 2, -faceSize / 4, logoWidth,
270                logoHeight, this);
271
272        // Draw hour hand rotated to appropriate angle
273        // Calculation mimics the AffineTransform class calculations in Graphics2D
274        // Grpahics2D and AffineTransform not used to maintain compatabilty with Java 1.1.8
275        double minuteAngleRadians = Math.toRadians(minuteAngle);
276        for (int i = 0; i < scaledMinuteX.length; i++) {
277            rotatedMinuteX[i] = (int) (scaledMinuteX[i] * Math.cos(minuteAngleRadians)
278                    - scaledMinuteY[i] * Math.sin(minuteAngleRadians));
279            rotatedMinuteY[i] = (int) (scaledMinuteX[i] * Math.sin(minuteAngleRadians)
280                    + scaledMinuteY[i] * Math.cos(minuteAngleRadians));
281        }
282        scaledMinuteHand = new Polygon(rotatedMinuteX, rotatedMinuteY, rotatedMinuteX.length);
283        double hourAngleRadians = Math.toRadians(hourAngle);
284        for (int i = 0; i < scaledHourX.length; i++) {
285            rotatedHourX[i] = (int) (scaledHourX[i] * Math.cos(hourAngleRadians)
286                    - scaledHourY[i] * Math.sin(hourAngleRadians));
287            rotatedHourY[i] = (int) (scaledHourX[i] * Math.sin(hourAngleRadians)
288                    + scaledHourY[i] * Math.cos(hourAngleRadians));
289        }
290        scaledHourHand = new Polygon(rotatedHourX, rotatedHourY,
291                rotatedHourX.length);
292
293        g.fillPolygon(scaledHourHand);
294        g.fillPolygon(scaledMinuteHand);
295
296        // Draw AM/PM indicator in slightly smaller font than hour digits
297        int amPmFontSize = (int) (faceSize * .075);
298        if (amPmFontSize < 1) {
299            amPmFontSize = 1;
300        }
301        Font amPmSizedFont = new Font("Serif", Font.BOLD, amPmFontSize);
302        g.setFont(amPmSizedFont);
303        FontMetrics amPmFontM = g.getFontMetrics(amPmSizedFont);
304
305        g.drawString(amPm, -amPmFontM.stringWidth(amPm) / 2, faceSize / 5);
306    }
307
308    // Method to provide the cartesian x coordinate given a radius and angle (in degrees)
309    int dotX(double radius, double angle) {
310        int xDist;
311        xDist = (int) Math.round(radius * Math.cos(Math.toRadians(angle)));
312        return xDist;
313    }
314
315    // Method to provide the cartesian y coordinate given a radius and angle (in degrees)
316    int dotY(double radius, double angle) {
317        int yDist;
318        yDist = (int) Math.round(radius * Math.sin(Math.toRadians(angle)));
319        return yDist;
320    }
321
322    // Method called on resizing event - sets various sizing variables
323    // based on the size of the resized panel and scales the logo/hands
324    private void scaleFace() {
325        panelHeight = this.getSize().height;
326        panelWidth = this.getSize().width;
327        if (panelHeight > 0 && panelWidth > 0) {
328            size = Math.min(panelHeight, panelWidth);
329        }
330        faceSize = size;
331        if (faceSize <= 12) {
332            return;
333        }
334
335        // Had trouble getting the proper sizes when using Images by themselves so
336        // use the NamedIcon as a source for the sizes
337        int logoScaleWidth = faceSize / 6;
338        int logoScaleHeight = (int) ((float) logoScaleWidth
339                * (float) jmriIcon.getIconHeight()
340                / jmriIcon.getIconWidth());
341        scaledLogo = logo.getScaledInstance(logoScaleWidth, logoScaleHeight,
342                Image.SCALE_SMOOTH);
343        scaledIcon.setImage(scaledLogo);
344        logoWidth = scaledIcon.getIconWidth();
345        logoHeight = scaledIcon.getIconHeight();
346
347        scaleRatio = faceSize / 2.7 / minuteHeight;
348        for (int i = 0; i < minuteX.length; i++) {
349            scaledMinuteX[i] = (int) (minuteX[i] * scaleRatio);
350            scaledMinuteY[i] = (int) (minuteY[i] * scaleRatio);
351            scaledHourX[i] = (int) (hourX[i] * scaleRatio);
352            scaledHourY[i] = (int) (hourY[i] * scaleRatio);
353        }
354        scaledHourHand = new Polygon(scaledHourX, scaledHourY,
355                scaledHourX.length);
356        scaledMinuteHand = new Polygon(scaledMinuteX, scaledMinuteY,
357                scaledMinuteX.length);
358
359        if (panelHeight > 0 && panelWidth > 0) {
360            centreX = panelWidth / 2;
361            centreY = panelHeight / 2;
362        } else {
363            centreX = centreY = size / 2;
364        }
365    }
366
367    public void setSize(int x) {
368        size = x;
369        setSize(x, x);
370        scaleFace();
371    }
372
373    /* This needs to be updated if resizing becomes an option
374     public void resize() {
375     int panelHeight = this.getSize().height;
376     int panelWidth = this.getSize().width;
377     size = Math.min(panelHeight, panelWidth);
378     scaleFace();
379     }
380     */
381    @SuppressWarnings("deprecation") // Date.getTime
382    public void update() {
383        Date now = clock.getTime();
384        if (runMenu != null) {
385            runMenu.setText(getRun() ? "Stop" : "Start");
386        }
387        int hours = now.getHours();
388        int minutes = now.getMinutes();
389        minuteAngle = minutes * 6.;
390        hourAngle = hours * 30. + 30. * minuteAngle / 360.;
391        if (hours < 12) {
392            amPm = Bundle.getMessage("ClockAM");
393        } else {
394            amPm = Bundle.getMessage("ClockPM");
395        }
396        if (hours == 12 && minutes == 0) {
397            amPm = Bundle.getMessage("ClockNoon");
398        }
399        if (hours == 0 && minutes == 0) {
400            amPm = Bundle.getMessage("ClockMidnight");
401        }
402
403        // show either "Stopped" or rate, depending on state
404        if (! clock.getRun()) {
405            amPm = amPm + " "+Bundle.getMessage("ClockStopped");
406        } else {
407            // running, display rate
408            String rate = ""+(int)clock.userGetRate();
409            if (Math.floor(clock.userGetRate()) != clock.userGetRate()) {
410                var format = new java.text.DecimalFormat("0.###");  // no trailing zeros
411                rate = format.format(clock.userGetRate());
412            }
413
414            // add rate to amPm string for display
415            amPm = amPm + " " + rate + ":1";
416        }
417        repaint();
418    }
419
420    public boolean getRun() {
421        return clock.getRun();
422    }
423
424    public void setRun(boolean next) {
425        clock.setRun(next);
426    }
427
428    @Override
429    void cleanup() {
430    }
431
432    public void dispose() {
433        rateButtonGroup = null;
434        runMenu = null;
435    }
436
437    @Override
438    public String getURL() {
439        return _url;
440    }
441
442    @Override
443    public void setULRL(String u) {
444        _url = u;
445    }
446
447    @Override
448    public boolean setLinkMenu(JPopupMenu popup) {
449        if (_url == null || _url.trim().length() == 0) {
450            return false;
451        }
452        popup.add(CoordinateEdit.getLinkEditAction(this, "EditLink"));
453        return true;
454    }
455
456    @Override
457    public void doMouseClicked(JmriMouseEvent event) {
458        log.debug("click to {}", _url);
459        if (_url == null || _url.trim().length() == 0) {
460            return;
461        }
462        try {
463            if (_url.startsWith("frame:")) {
464                // locate JmriJFrame and push to front
465                String frame = _url.substring(6);
466                final jmri.util.JmriJFrame jframe = jmri.util.JmriJFrame.getFrame(frame);
467                java.awt.EventQueue.invokeLater(() -> {
468                    jframe.toFront();
469                    jframe.repaint();
470                });
471            } else {
472                jmri.util.HelpUtil.openWebPage(_url);
473            }
474        } catch (JmriException t) {
475            log.error("Error handling link", t);
476        }
477        super.doMouseClicked(event);
478    }
479
480    private static final Logger log = LoggerFactory.getLogger(AnalogClock2Display.class);
481}