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 int 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 = Integer.toString(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 private void drawPointer(Graphics2D g2) { 286 float speedAngle = getSpeedAngle(speed); 287 double speedAngleRadians = Math.toRadians(speedAngle -144); 288 for (int i = 0; i < scaledPointerX.length; i++) { 289 rotatedPointerX[i] = (int) (scaledPointerX[i] * Math.cos(speedAngleRadians) 290 - scaledPointerY[i] * Math.sin(speedAngleRadians)); 291 rotatedPointerY[i] = (int) (scaledPointerX[i] * Math.sin(speedAngleRadians) 292 + scaledPointerY[i] * Math.cos(speedAngleRadians)); 293 } 294 scaledPointerHand = new Polygon(rotatedPointerX, rotatedPointerY, rotatedPointerX.length); 295 g2.setColor(centreCircleAndDialColor); 296 g2.fillPolygon(scaledPointerHand); 297 } 298 299 private void drawSpeedUnit(Graphics2D g2){ 300 if (displaySpeedUnit.isBlank()) { 301 return; 302 } 303 Font unitsSizedFont = new Font(DmiPanel.FONT_NAME, Font.BOLD, 16); 304 g2.setFont(unitsSizedFont); 305 g2.setColor(DmiPanel.GREY); 306 FontMetrics unitsFontM = g2.getFontMetrics(unitsSizedFont); 307 g2.drawString(displaySpeedUnit, - unitsFontM.stringWidth(displaySpeedUnit) / 2, 50); 308 } 309 310 private void drawTargetAdviceSpeed(Graphics2D g2){ 311 log.debug("targetAS {}", targetAdviceSpeed); 312 if ( targetAdviceSpeed < 0){ 313 return; 314 } 315 g2.setColor(DmiPanel.MEDIUM_GREY); 316 float i = getSpeedAngle(targetAdviceSpeed)+DEGREES_SPEED0_CLOCKWISE; 317 g2.fillOval(dotX(111, i )-5, dotY(111, i)-5, 10, 10); 318 } 319 320 /** 321 * Get the Angle from speed 0 at 0 degrees. 322 * Maximum speed always at 288 degrees. 323 * Maximum speeds above 299 are scaled at 50% from speed 200 upwards. 324 * @param speed to locate angle for. 325 * @return Angle for the speed 326 */ 327 private float getSpeedAngle( float speed ){ 328 float angleForSpeed1 = 288f / maxSpeed; 329 if ( maxSpeed >299 ){ 330 angleForSpeed1 *= (maxSpeed/( 200f+((maxSpeed-200f)/2))); 331 if ( speed >= 200 ) { 332 return angleForSpeed1 * 200f + angleForSpeed1 * (speed-200f) / 2f; 333 } 334 } 335 return angleForSpeed1 * speed; 336 } 337 338 // Method to provide the cartesian x coordinate given a radius and angle (in degrees) 339 private int dotX(float radius, float angle) { 340 return (int) Math.round(radius * Math.cos(Math.toRadians(angle))); 341 } 342 343 // Method to provide the cartesian y coordinate given a radius and angle (in degrees) 344 private int dotY(float radius, float angle) { 345 return (int)Math.round(radius * Math.sin(Math.toRadians(angle))); 346 } 347 348 protected void update(float speed) { 349 if (speed > maxSpeed) { 350 log.debug("add code here to move scale up"); 351 } 352 this.speed = Math.round(speed); 353 repaint(); 354 } 355 356 protected void setDisplaySpeedUnit(String newVal){ 357 displaySpeedUnit = newVal; 358 repaint(); 359 } 360 361 protected void setMaxDialSpeed(int newSpd){ 362 maxSpeed = newSpd; 363 majorSpeedGap = ( newSpd > 251 ? 50 : 20 ); 364 repaint(); 365 } 366 367 protected void setCentreCircleAndDialColor(Color newColor ) { 368 centreCircleAndDialColor = newColor; 369 } 370 371 protected void setCsgSections(List<DmiCircularSpeedGuideSection> list){ 372 csgSectionList.clear(); 373 csgSectionList.addAll(list); 374 repaint(); 375 } 376 377 protected void setTargetAdviceSpeed(int newVal){ 378 targetAdviceSpeed = newVal; 379 repaint(); 380 } 381 382 private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(DmiSpeedoDialPanel.class); 383 384}