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