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}