001package jmri.jmrit.etcs.dmi.swing;
002
003import java.awt.AlphaComposite;
004import java.awt.Color;
005import java.awt.Font;
006import java.awt.FontMetrics;
007import java.awt.Graphics;
008import java.awt.Graphics2D;
009import java.awt.Polygon;
010import java.awt.RenderingHints;
011import java.awt.image.BufferedImage;
012
013import java.util.List;
014import java.util.concurrent.CopyOnWriteArrayList;
015
016import javax.swing.JPanel;
017
018/**
019 * Creates a JPanel containing an Dial type speedometer display.
020 * <p>
021 * Based on analogue clock frame by Dennis Miller
022 *
023 * @author Andrew Crosland Copyright (C) 2010
024 * @author Dennis Miller Copyright (C) 2015
025 * @author Steve Young Copyright (C) 2023
026 */
027public class DmiSpeedoDialPanel extends JPanel {
028
029    private float speed = 0;
030    private float targetAdviceSpeed = -1; // unset
031    private int maxSpeed = 140;
032    private int majorSpeedGap = 20; // every 20 mph from 0
033
034    private Color centreCircleAndDialColor = DmiPanel.GREY;
035    private String displaySpeedUnit = "";
036
037    private static final int DIAL_CENTRE_X = 140;
038    private static final int DIAL_CENTRE_Y = 150;
039    private static final float DEGREES_SPEED0_CLOCKWISE = 126;
040    private static final int HOOK_DEPTH = 20;
041
042    private static final int OUTER_CSG_RADIUS = 137;
043
044    private final int[] pointerX = {-20,   -20,   -7,   -7,    7,    7,   20,  20};
045    private final int[] pointerY = {-31,  -266, -314, -381, -381, -314, -266, -31};
046    private final int[] scaledPointerX = new int[pointerX.length];
047    private final int[] scaledPointerY = new int[pointerY.length];
048    private final int[] rotatedPointerX = new int[pointerX.length];
049    private final int[] rotatedPointerY = new int[pointerY.length];
050
051    private Polygon scaledPointerHand;
052    private final int pointerHeight;
053
054    private final CopyOnWriteArrayList<DmiCircularSpeedGuideSection> csgSectionList;
055    private final jmri.UserPreferencesManager p;
056
057    public DmiSpeedoDialPanel() {
058        super();
059
060        p = jmri.InstanceManager.getDefault(jmri.UserPreferencesManager.class);
061        setLayout(null);
062        setOpaque(false);
063
064        csgSectionList = new CopyOnWriteArrayList<>();
065
066        // Create an unscaled pointer to get the original size (height)to use
067        // in the scaling calculations
068        Polygon pointerHand = new Polygon(pointerX, pointerY, pointerY.length);
069        pointerHeight = pointerHand.getBounds().getSize().height;
070
071        float scaleRatio = 250 / 2.6F / pointerHeight;
072        for (int i = 0; i < pointerX.length; i++) {
073            scaledPointerX[i] = (int) (pointerX[i] * scaleRatio);
074            scaledPointerY[i] = (int) (pointerY[i] * scaleRatio);
075        }
076        scaledPointerHand = new Polygon(scaledPointerX, scaledPointerY, scaledPointerX.length);
077        DmiSpeedoDialPanel.this.setMaxDialSpeed(140);
078        DmiSpeedoDialPanel.this.update(0); // default to 0 speed
079    }
080
081    @Override
082    public void paint(Graphics g) {
083        if (!(g instanceof Graphics2D) ) {
084              throw new IllegalArgumentException("Graphics object passed is not the correct type");
085        }
086        Graphics2D g2 = (Graphics2D) g;
087
088        // coordinates of the centre of the dial in the panel.
089        g2.translate(DIAL_CENTRE_X, DIAL_CENTRE_Y);
090
091        RenderingHints  hints =new RenderingHints(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
092        hints.put(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
093        g2.setRenderingHints(hints);
094
095        drawTicksAndDigits(g2, 125);
096        csgSectionList.forEach((var s) -> drawACsgSection(g2, s )); // after ticks
097
098        drawTargetAdviceSpeed(g2);
099        drawPointer(g2);
100        drawCentreCircle(g2); // after Pointer so is on top
101
102        drawSpeedUnit(g2);
103    }
104
105    private void drawHook(Graphics2D g2, DmiCircularSpeedGuideSection section){
106
107        int hookWidth = 4;
108        boolean miniMook = section.type==DmiCircularSpeedGuideSection.CSG_TYPE_SUPERVISION;
109        float hookWidthAngle = DEGREES_SPEED0_CLOCKWISE - hookWidth;
110        float angle = getSpeedAngle(section.stop);
111        int hookOuterRadius = OUTER_CSG_RADIUS - (miniMook ? 6 : 0);
112        int hookInnerRadius = OUTER_CSG_RADIUS - HOOK_DEPTH;
113    
114        g2.setColor(miniMook ? DmiPanel.YELLOW: section.col);
115        Polygon polygon2 = new Polygon();
116        polygon2.addPoint(dotX(hookOuterRadius, angle+DEGREES_SPEED0_CLOCKWISE), 
117            dotY(hookOuterRadius, angle+DEGREES_SPEED0_CLOCKWISE));
118
119        polygon2.addPoint(dotX(hookInnerRadius, angle+DEGREES_SPEED0_CLOCKWISE),
120            dotY(hookInnerRadius, angle+DEGREES_SPEED0_CLOCKWISE));
121        polygon2.addPoint(dotX(hookInnerRadius, angle+hookWidthAngle),
122            dotY(hookInnerRadius, angle+hookWidthAngle));
123        polygon2.addPoint(dotX(hookOuterRadius, angle+hookWidthAngle),
124            dotY(hookOuterRadius, angle+hookWidthAngle));
125        g2.fillPolygon(polygon2);
126    }
127
128    private void drawACsgSection(Graphics2D g, DmiCircularSpeedGuideSection section ){
129
130        float startAngle = getSpeedAngle(section.start)+ DEGREES_SPEED0_CLOCKWISE;
131        float endAngle = getSpeedAngle(section.stop) - getSpeedAngle(section.start);
132
133        if ( section.includeNegative ) {
134            startAngle -= 5;
135            endAngle += 5;
136        }
137
138        log.debug("endAngle: {}", endAngle);
139
140        int innerRadius = 9;
141        int outerRadius = OUTER_CSG_RADIUS;
142        if ( section.type == DmiCircularSpeedGuideSection.CSG_TYPE_SUPERVISION ){
143            outerRadius = OUTER_CSG_RADIUS-5;
144        }
145
146        if ( section.type == DmiCircularSpeedGuideSection.CSG_TYPE_NORMAL ){
147            innerRadius = 9;
148        } else if ( section.type == DmiCircularSpeedGuideSection.CSG_TYPE_HOOK ){
149            innerRadius = HOOK_DEPTH;
150        }
151        if ( section.type == DmiCircularSpeedGuideSection.CSG_TYPE_RELEASE ){ // outer edge, grey
152            innerRadius = 5;
153        }
154        if ( section.type == DmiCircularSpeedGuideSection.CSG_TYPE_SUPERVISION ){ // inner edge, yellow
155            innerRadius = 5;
156        }
157
158        // Create a buffered image for rendering
159        int width = getWidth();
160        int height = getHeight();
161        BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
162        Graphics2D g2d = image.createGraphics();
163
164        RenderingHints hints =new RenderingHints(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
165        hints.put(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
166
167        g2d.setRenderingHints(hints);
168        g2d.translate(DIAL_CENTRE_X, DIAL_CENTRE_Y);
169
170        if ( section.type == DmiCircularSpeedGuideSection.CSG_TYPE_NORMAL || section.type == DmiCircularSpeedGuideSection.CSG_TYPE_HOOK ) {
171            drawAnArc(g2d, false, section.col,
172                outerRadius, (int) startAngle, (int) endAngle);
173            drawAnArc(g2d, true, section.col,
174                outerRadius-innerRadius, (int) startAngle, (int) endAngle);
175        }
176
177        if ( section.type == DmiCircularSpeedGuideSection.CSG_TYPE_RELEASE ) {
178            drawAnArc(g2d, false, DmiPanel.GREY,
179                outerRadius, (int) startAngle, (int) endAngle);
180            drawAnArc(g2d, false, DmiPanel.BACKGROUND_COLOUR,
181                outerRadius-innerRadius+1, (int) startAngle, (int) endAngle);
182            drawAnArc(g2d, true, section.col,
183                outerRadius-innerRadius, (int) startAngle, (int) endAngle);
184        }
185
186        if ( section.type == DmiCircularSpeedGuideSection.CSG_TYPE_SUPERVISION ) {
187            drawAnArc(g2d, false, DmiPanel.BACKGROUND_COLOUR,
188                outerRadius, (int) startAngle, (int) endAngle);
189            drawAnArc(g2d, false, DmiPanel.YELLOW,
190                outerRadius-1, (int) startAngle, (int) endAngle);
191            drawAnArc(g2d, true, section.col,
192                outerRadius-innerRadius, (int) startAngle, (int) endAngle);
193            drawHook(g, section);
194        }
195
196        // Draw the modified image onto the JPanel
197        g.drawImage(image, -DIAL_CENTRE_X, -DIAL_CENTRE_Y, null);
198
199        // Dispose of graphics objects
200        g2d.dispose();
201
202        if ( section.includeHook ) {
203            drawHook(g, section);
204        }
205    }
206
207    private void drawAnArc( Graphics2D g2, boolean transparent, Color color,
208        int radius, int startAngle, int endAngle ) {
209
210        if (transparent) {
211            g2.setComposite(AlphaComposite.getInstance(AlphaComposite.DST_ATOP, 0f));
212        } else {
213            g2.setColor(color);
214        }
215
216        g2.fillArc(
217            -radius, -radius,
218            2 * radius , 2 * radius,
219            (- startAngle) + (transparent ? 2 : 0),
220            (- endAngle)   - (transparent ? 4 : 0));
221    }
222
223    private void drawTicksAndDigits(Graphics2D g2, float halfFaceSize) {
224
225        Font sizedFont = new Font(DmiPanel.FONT_NAME, Font.BOLD, 18);
226        g2.setFont(sizedFont);
227        FontMetrics fontM = g2.getFontMetrics(sizedFont);
228        g2.setColor(Color.WHITE);
229        for (int j = 0; j <= maxSpeed; j += 10) {
230            float i = getSpeedAngle(j)+DEGREES_SPEED0_CLOCKWISE;
231            log.debug("angle is {}", i);
232            if (j >= 0 && (j % majorSpeedGap != 0)) {
233                // minor tick every 10, excluding major ticks
234                g2.drawLine(
235                dotX(halfFaceSize, i), 
236                dotY(halfFaceSize, i),
237                dotX(halfFaceSize - 15, i), 
238                dotY(halfFaceSize - 15, i));
239            } else {
240                // major tick with Speed String
241                g2.drawLine(
242                    dotX(halfFaceSize, i), 
243                    dotY(halfFaceSize, i),
244                    dotX(halfFaceSize - 25, i), 
245                    dotY(halfFaceSize - 25, i));
246
247                // ertms3.6 only draw big speeds over 250 in hundreds.
248                // if ( majorSpeedGap > 20 && j > 251 && (j % 100) !=0) {
249
250                // ertms4.0 only draw big speeds over 201 in hundreds.
251                if ( majorSpeedGap > 20 && j > 201 && (j % 100) !=0) {
252                    continue;
253                }
254
255                String dashSpeed = Integer.toString( j);
256                int xOffset = fontM.stringWidth(dashSpeed);
257                int yOffset = fontM.getHeight();
258                g2.drawString(dashSpeed,
259                    dotX(halfFaceSize - 40, i ) - xOffset / 2,
260                    dotY(halfFaceSize - 40, i ) + yOffset / 4);
261            }
262        }
263    }
264
265    private void drawCentreCircle(Graphics2D g2) {
266
267        // create centre circle
268        g2.setColor(centreCircleAndDialColor);
269        int dotSize = 20;
270        g2.fillOval(-dotSize, -dotSize, dotSize *2, dotSize*2);
271
272        // display the speed value in centre of the centre circle
273        String speedString = getSpeedString(speed);
274        Font digitsSizedFont = new Font(DmiPanel.FONT_NAME, Font.BOLD, 22);
275        g2.setFont(digitsSizedFont);
276        g2.setColor( centreCircleAndDialColor==DmiPanel.RED ? Color.WHITE : Color.BLACK);
277        FontMetrics digitsFontM = g2.getFontMetrics(digitsSizedFont);
278        if ( p.getSimplePreferenceState(DmiPanel.PROPERTY_CENTRE_TEXT) ) {
279            g2.drawString(speedString, -digitsFontM.stringWidth(speedString) / 2 , 7);
280        } else { // right-align
281            g2.drawString(speedString, 18-digitsFontM.stringWidth(speedString) , 7);
282        }
283    }
284
285    /**
286     * Get a String for the Speed value.
287     * Speeds are displayed to nearest whole number.
288     * If the speed is non-zero, the minimum displayable speed is 1 .
289     * @param speed the loco speed.
290     * @return formatted String.
291     */
292    private String getSpeedString(float speed) {
293        if (speed > 0f) { // round to nearest whole number
294            return String.valueOf(Math.max(1, Math.round(speed)));
295        } else {
296            return "0";
297        }
298    }
299
300    private void drawPointer(Graphics2D g2) {
301        float speedAngle = getSpeedAngle(speed);
302        double speedAngleRadians = Math.toRadians(speedAngle -144);
303        for (int i = 0; i < scaledPointerX.length; i++) {
304            rotatedPointerX[i] = (int) (scaledPointerX[i] * Math.cos(speedAngleRadians)
305                    - scaledPointerY[i] * Math.sin(speedAngleRadians));
306            rotatedPointerY[i] = (int) (scaledPointerX[i] * Math.sin(speedAngleRadians)
307                    + scaledPointerY[i] * Math.cos(speedAngleRadians));
308        }
309        scaledPointerHand = new Polygon(rotatedPointerX, rotatedPointerY, rotatedPointerX.length);
310        g2.setColor(centreCircleAndDialColor);
311        g2.fillPolygon(scaledPointerHand);
312    }
313
314    private void drawSpeedUnit(Graphics2D g2){
315        if (displaySpeedUnit.isBlank()) {
316            return;
317        }
318        Font unitsSizedFont = new Font(DmiPanel.FONT_NAME, Font.BOLD, 16);
319        g2.setFont(unitsSizedFont);
320        g2.setColor(DmiPanel.GREY);
321        FontMetrics unitsFontM = g2.getFontMetrics(unitsSizedFont);
322        g2.drawString(displaySpeedUnit,  - unitsFontM.stringWidth(displaySpeedUnit) / 2, 50);
323    }
324
325    private void drawTargetAdviceSpeed(Graphics2D g2){
326        log.debug("targetAS {}", targetAdviceSpeed);
327        if ( targetAdviceSpeed < 0){
328            return;
329        }
330        g2.setColor(DmiPanel.MEDIUM_GREY);
331        float i = getSpeedAngle(targetAdviceSpeed)+DEGREES_SPEED0_CLOCKWISE;
332        g2.fillOval(dotX(111, i )-5, dotY(111, i)-5, 10, 10);
333    }
334
335    /**
336     * Get the Angle from speed 0 at 0 degrees.
337     * Maximum speed always at 288 degrees.
338     * Maximum speeds above 299 are scaled at 50% from speed 200 upwards.
339     * @param speed to locate angle for.
340     * @return Angle for the speed
341     */
342    private float getSpeedAngle( float speed ){
343        float angleForSpeed1 = 288f / maxSpeed;
344        if ( maxSpeed >299 ){
345            angleForSpeed1 *= (maxSpeed/( 200f+((maxSpeed-200f)/2)));
346            if ( speed >= 200 ) {
347                return angleForSpeed1 * 200f + angleForSpeed1 * (speed-200f) / 2f;
348            }
349        }
350        return angleForSpeed1 * speed;
351    }
352
353    // Method to provide the cartesian x coordinate given a radius and angle (in degrees)
354    private int dotX(float radius, float angle) {
355        return (int) Math.round(radius * Math.cos(Math.toRadians(angle)));
356    }
357
358    // Method to provide the cartesian y coordinate given a radius and angle (in degrees)
359    private int dotY(float radius, float angle) {
360        return (int)Math.round(radius * Math.sin(Math.toRadians(angle)));
361    }
362
363    protected void update(float speed) {
364        if (speed > maxSpeed) {
365            log.debug("add code here to move scale up");
366        }
367        this.speed = speed;
368        repaint();
369    }
370
371    protected void setDisplaySpeedUnit(String newVal){
372        displaySpeedUnit = newVal;
373        repaint();
374    }
375
376    protected void setMaxDialSpeed(int newSpd){
377        maxSpeed = newSpd;
378        majorSpeedGap = ( newSpd > 251 ? 50 : 20 );
379        repaint();
380    }
381
382    protected void setCentreCircleAndDialColor(Color newColor ) {
383        centreCircleAndDialColor = newColor;
384    }
385
386    protected void setCsgSections(List<DmiCircularSpeedGuideSection> list){
387        csgSectionList.clear();
388        csgSectionList.addAll(list);
389        repaint();
390    }
391
392    protected void setTargetAdviceSpeed(int newVal){
393        targetAdviceSpeed = newVal;
394        repaint();
395    }
396
397    private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(DmiSpeedoDialPanel.class);
398
399}