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    public void update() {
390        Date now = clock.getTime();
391        if (runMenu != null) {
392            runMenu.setText(getRun() ? "Stop" : "Start");
393        }
394        int hours = now.getHours();
395        int minutes = now.getMinutes();
396        minuteAngle = minutes * 6.;
397        hourAngle = hours * 30. + 30. * minuteAngle / 360.;
398        if (hours < 12) {
399            amPm = Bundle.getMessage("ClockAM");
400        } else {
401            amPm = Bundle.getMessage("ClockPM");
402        }
403        if (hours == 12 && minutes == 0) {
404            amPm = Bundle.getMessage("ClockNoon");
405        }
406        if (hours == 0 && minutes == 0) {
407            amPm = Bundle.getMessage("ClockMidnight");
408        }
409
410        // show either "Stopped" or rate, depending on state
411        if (! clock.getRun()) {
412            amPm = amPm + " "+Bundle.getMessage("ClockStopped");
413        } else {
414            // running, display rate
415            String rate = ""+(int)clock.userGetRate();
416            if (Math.floor(clock.userGetRate()) != clock.userGetRate()) {
417                var format = new java.text.DecimalFormat("0.###");  // no trailing zeros
418                rate = format.format(clock.userGetRate());
419            }
420
421            // add rate to amPm string for display
422            amPm = amPm + " " + rate + ":1";
423        }
424        repaint();
425    }
426
427    public boolean getRun() {
428        return clock.getRun();
429    }
430
431    public void setRun(boolean next) {
432        clock.setRun(next);
433    }
434
435    @Override
436    void cleanup() {
437    }
438
439    public void dispose() {
440        rateButtonGroup = null;
441        runMenu = null;
442    }
443
444    @Override
445    public String getURL() {
446        return _url;
447    }
448
449    @Override
450    public void setULRL(String u) {
451        _url = u;
452    }
453
454    @Override
455    public boolean setLinkMenu(JPopupMenu popup) {
456        if (_url == null || _url.trim().length() == 0) {
457            return false;
458        }
459        popup.add(CoordinateEdit.getLinkEditAction(this, "EditLink"));
460        return true;
461    }
462
463    @Override
464    public void doMouseClicked(JmriMouseEvent event) {
465        log.debug("click to {}", _url);
466        if (_url == null || _url.trim().length() == 0) {
467            return;
468        }
469        try {
470            if (_url.startsWith("frame:")) {
471                // locate JmriJFrame and push to front
472                String frame = _url.substring(6);
473                final jmri.util.JmriJFrame jframe = jmri.util.JmriJFrame.getFrame(frame);
474                java.awt.EventQueue.invokeLater(() -> {
475                    jframe.toFront();
476                    jframe.repaint();
477                });
478            } else {
479                jmri.util.HelpUtil.openWebPage(_url);
480            }
481        } catch (JmriException t) {
482            log.error("Error handling link", t);
483        }
484        super.doMouseClicked(event);
485    }
486
487    private static final Logger log = LoggerFactory.getLogger(AnalogClock2Display.class);
488}