// Axis.java import java.util.Vector; import java.util.Enumeration; import java.text.NumberFormat; import java.awt.Graphics; // to implement high-level drawAxis method. // imports for test code only import java.awt.*; import java.awt.event.*; import javax.swing.*; import javax.swing.event.*; import FloatSlider; /** * includes a static function for selecting and labeling graph axis tic labels. * given a numeric range and a maximum number of tics, * this class can produce a list of labels with the nicest round numbers * not exceeding a given maximum number of labels. * the label generation code was extracted from the public domain * <a href="http://ptolemy.eecs.berkeley.edu/">Ptolomy project</a> * at UC Berkeley, taken from ptolemy/plot/PlotBox.java. * * i added another static method to compute and draw an axis into * a given AWT Graphics object. i extracted the code for producing linear * labels and threw out the vast majority of code that attempted to produce * log scale labels since that code was very broken. it was noted int the * Ptolomy code that the log label generation was itself based on * a file named xgraph.c by David Harrisonmy, and the comments say that * the original code breaks down in certain cases. my drawAxis method * can still draw nicely labeling log scale axes because i simply use * the linear scale label generation code from Ptolomy and plot the tics in * their proper locations on a log scale. the resulting code produced exactly * the same results as the Ptolemy code for log scales in those ranges where * the Ptolemy code did work, so this design is much better in all cases and * uses only a fraction of the original complexity. still, it can probably * be further improved though the exact problem is not well defined. * * @author Melinda Green */ public class Axis { private static final boolean DEBUG = false; public final static int X_AXIS = 0, Y_AXIS = 1; // For use in calculating log base 10. A log times this is a log base 10. private static final double LOG10SCALE = 1/Math.log(10); // handy static methods public static double log10(double val) { return Math.log(val) * LOG10SCALE; } public static double exp10(double val) { return Math.exp(val / LOG10SCALE); } public static float flog10(double val) { return (float)log10(val); } public static float fexp10(double val) { return (float)exp10(val); } /** * this is the central method of this class. * takes axis range parameters and produces a list of string * representations of nicely rounded numbers within the given range. * these strings are intended for use as axis tic labels. * note: to find out where to plot each tic label simply * use <br><code>float ticval = Float.parseFloat(ticstring);</code> * @param ticMinVal no tics will be created for less than this value. * @param ticMaxVal no tics will be created for greater than this value. * @param maxTics returned vector will contain no more labels than this number. * @return a Vector containing formatted label strings which should also * be parsable into floating point numbers (in order to plot them). */ public static Vector computeTicks(double ticMinVal, double ticMaxVal, int maxTicks) { double xStep = roundUp((ticMaxVal-ticMinVal)/maxTicks); int numfracdigits = numFracDigits(xStep); // Compute x starting point so it is a multiple of xStep. double xStart = xStep*Math.ceil(ticMinVal/xStep); Vector xgrid = null; Vector labels = new Vector(); // Label the axis. The labels are quantized so that // they don't have excess resolution. for (double xpos=xStart; xpos<=ticMaxVal; xpos+=xStep) labels.addElement(formatNum(xpos, numfracdigits)); return labels; } /** * high-level method for drawing a chart axis line plus labeled tic marks. * introduces a dependancy on AWT because it takes a Graphics parameter. * perhaps this method belongs in some higher-level class but i added it * here since it's highly related with the tic lable generation code. * * @author Melinda Green * * @param axis is one of Axis.X_AXIS or Axis.Y_AXIS. * @param maxTics is the maximum number of labeled tics to draw. * note: the actual number drawn may be less. * @param lowVal is the smallest value tic mark that may be drawn. * note: the lowest valued tic label may be greater than this limit. * @param highVal is the largest value tic mark that may be drawn. * note: the highest valued tic label may be less than this limit. * @param screenStart is the coordinate in the low valued direction. * @param screenEnd is the coordinate in the high valued direction. * @param offset is the coordinate in the direction perpendicular to * the specified direction. * @param logScale is true if a log scale axis is to be drawn, * false for a linear scale. * @param screenHeight is needed to flip Y coordinates. * @param g is the AWT Graphics object to draw into. * note: all drawing will be done in the current color of the given * Graphics object. */ public static void drawAxis( int axis, int maxTics, int ticLength, float lowVal, float highVal, int screenStart, int screenEnd, int screenOffset, boolean logScale, int screenHeight, Graphics g) { if(logScale && (lowVal == 0 || highVal == 0)) throw new IllegalArgumentException("Axis.drawAxis: zero range value not allowed in log axes"); if(axis == X_AXIS) // horizontal baseline g.drawLine(screenStart, screenHeight-screenOffset, screenEnd, screenHeight-screenOffset); else // vertical baseline g.drawLine(screenOffset, screenStart, screenOffset, screenEnd); Vector tics = Axis.computeTicks(lowVal, highVal, maxTics); // nice round numbers for tic labels int last_label_end = axis == X_AXIS ? -88888 : 88888; String dbgstr = "tics: "; for(Enumeration e=tics.elements(); e.hasMoreElements(); ) { String ticstr = (String)e.nextElement(); if(DEBUG) dbgstr += ticstr + ", "; float ticval = Float.parseFloat(ticstr); int tic_coord = screenStart; Dimension str_size = stringSize(ticstr, g); tic_coord += plotValue(ticval, lowVal, highVal, screenStart, screenEnd, logScale, screenHeight); if (axis == X_AXIS) { // horizontal axis == vertical tics g.drawLine( tic_coord, screenHeight-screenOffset, tic_coord, screenHeight-screenOffset+ticLength); if (tic_coord-str_size.width/2 > last_label_end) { g.drawString(ticstr, tic_coord-str_size.width/2, screenHeight-screenOffset+str_size.height+5); last_label_end = tic_coord + str_size.width/2 + str_size.height/2; } } else { // vertical axis == horizontal tics tic_coord = screenHeight - tic_coord; // flips Y coordinates g.drawLine( screenOffset-ticLength, tic_coord, screenOffset, tic_coord); if (tic_coord-str_size.height/3 < last_label_end) { g.drawString(ticstr, screenOffset-ticLength-str_size.width-5, tic_coord+str_size.height/3); last_label_end = tic_coord - str_size.height; } } } if(DEBUG) System.out.println(dbgstr); } // end drawAxis /** * lower level method to determine a screen location where a given value * should be plotted given range, type, and screen information. * the "val" parameter is the data value to be plotted * @author Melinda Green * @param val is a data value to be plotted. * @return pixel offset (row or column) to draw a screen representation * of the given data value. i.e. <i>where</i> along an axis * in screen coordinates the caller should draw a representation of * the given value. * @see drawAxis(int,int,int,float,float,int,int,int,boolean,int,Graphics) */ public static int plotValue(float val, float lowVal, float highVal, int screenStart, int screenEnd, boolean logScale, int screenHeight) { if(logScale && (lowVal == 0 || highVal == 0 || val == 0)) throw new IllegalArgumentException("Axis.drawAxis: zero range value not allowed in log axes"); int screen_range = screenEnd - screenStart; // in pixels if (logScale) { float log_low = flog10(lowVal), log_high = flog10(highVal), log_val = flog10(val); float log_range = log_high - log_low; float pixels_per_log_unit = screen_range / log_range; return (int)((log_val - log_low) * pixels_per_log_unit + .5); } else { float value_range = highVal - lowVal; // in data value units float pixels_per_unit = screen_range / value_range; return (int)((val-lowVal) * pixels_per_unit + .5); } } /* * Given a number, round up to the nearest power of ten * times 1, 2, or 5. * * Note: The argument must be strictly positive. */ private static double roundUp(double val) { int exponent = (int) Math.floor(log10(val)); val *= Math.pow(10, -exponent); if (val > 5.0) val = 10.0; else if (val > 2.0) val = 5.0; else if (val > 1.0) val = 2.0; val *= Math.pow(10, exponent); return val; } /* * Return the number of fractional digits required to display the * given number. No number larger than 15 is returned (if * more than 15 digits are required, 15 is returned). */ private static int numFracDigits(double num) { int numdigits = 0; while (numdigits <= 15 && num != Math.floor(num)) { num *= 10.0; numdigits += 1; } return numdigits; } // Number format cache used by formatNum. // Note: i'd have put the body of the formatNum method below into // a synchronized block for complete thread safety but that causes // an abscure null pointer exception in the awt event thread. // go figure. private static NumberFormat numberFormat = null; /* * Return a string for displaying the specified number * using the specified number of digits after the decimal point. * NOTE: java.text.NumberFormat is only present in JDK1.1 * We use this method as a wrapper so that we can cache information. */ private static String formatNum(double num, int numfracdigits) { if (numberFormat == null) { // Cache the number format so that we don't have to get // info about local language etc. from the OS each time. numberFormat = NumberFormat.getInstance(); // force to not include commas because we want the strings // to be parsable back into numeric values. - DRG numberFormat.setGroupingUsed(false); } numberFormat.setMinimumFractionDigits(numfracdigits); numberFormat.setMaximumFractionDigits(numfracdigits); return numberFormat.format(num); } /** * handy little utility for determining the length in pixels the * given string will use if drawn into the given Graphics object. * Note: perhaps belongs in some utility package. */ public static Dimension stringSize(String str, Graphics g) { if (g instanceof Graphics2D) { java.awt.geom.Rectangle2D bounds = g.getFont().getStringBounds(str, ((Graphics2D)g).getFontRenderContext()); return new Dimension( (int)(bounds.getWidth()+.5), (int)(bounds.getHeight()+.5)); } else return new Dimension(g.getFontMetrics().stringWidth(str), g.getFontMetrics().getHeight()); } // // TEST CODE FROM HERE DOWN // private static class TestPanel extends JPanel { private float curLowVal, curHighVal; private boolean logScale; public TestPanel(float initialLow, float initialHigh) { curLowVal = initialLow; curHighVal = initialHigh; } public void setLogScale(boolean logScale) { this.logScale = logScale; repaint(); } public void setLow(float val) { curLowVal = val; repaint(); } public void setHigh(float val) { curHighVal = val; repaint(); } public void paint(Graphics g) { super.paint(g); drawAxis(Axis.X_AXIS, 10, 5, curLowVal, curHighVal, 50, getWidth()-50, 50, logScale, getHeight(), g); drawAxis(Axis.Y_AXIS, 10, 5, curLowVal, curHighVal, 50, getHeight()-50, 50, logScale, getHeight(), g); g.drawString("Current Slider Range: " + curLowVal + " --> " + curHighVal, 10, 20); } } private static void addField(Container into, Component c, GridBagConstraints gbc, int x, int y, int w, int h, int wx, int wy) { gbc.gridx = x; gbc.gridy = y; gbc.gridwidth = w; gbc.gridheight = h; gbc.weightx = wx; gbc.weighty = wy; into.add(c, gbc); } /** * simple example program for Axis class. */ public static void main(String args[]) { final float INITIAL_MIN_LOW=1, INITIAL_MAX_HIGH=1000; final TestPanel axis = new TestPanel(INITIAL_MIN_LOW, INITIAL_MAX_HIGH); final JFrame frame = new JFrame("Axis Test"); JPanel mainpanel = new JPanel(new BorderLayout()); mainpanel.add("Center", axis); JPanel controls = new JPanel(); final JCheckBox logScale = new JCheckBox("Log"); logScale.addActionListener(new ActionListener() { public void actionPerformed(ActionEvent ae) { axis.setLogScale(logScale.isSelected()); } }); logScale.setSelected(true); axis.setLogScale(true); final JTextField minlow = new JTextField(""+INITIAL_MIN_LOW, 6); final FloatSlider lowSlider = new FloatSlider(JSlider.HORIZONTAL, INITIAL_MIN_LOW, 100, 1, 1000, 2000, false); final FloatSlider highSlider = new FloatSlider(JSlider.HORIZONTAL, INITIAL_MAX_HIGH, 100, 1, 1000, 2000, false); final JTextField maxhigh = new JTextField(""+INITIAL_MAX_HIGH, 6); GridBagLayout gridbag = new GridBagLayout(); controls.setLayout(gridbag); GridBagConstraints con = new GridBagConstraints(); con.fill = GridBagConstraints.BOTH; con.insets = new Insets(0, 10, 0, 0); addField(controls, logScale, con, 0, 0, 1, 1, 5, 100); addField(controls, new JLabel("min low value"), con, 1, 0, 1, 1, 10, 100); addField(controls, minlow, con, 2, 0, 1, 1, 10, 100); addField(controls, lowSlider, con, 3, 0, 1, 1, 50, 100); addField(controls, highSlider, con, 4, 0, 1, 1, 50, 100); addField(controls, new JLabel("max high value"), con, 5, 0, 1, 1, 10, 100); addField(controls, maxhigh, con, 6, 0, 1, 1, 10, 100); mainpanel.add("South", controls); KeyListener limitsWatcher = new KeyAdapter() { public void keyTyped(KeyEvent ke) { if(ke.getKeyChar() == KeyEvent.VK_ENTER) { float newminlow = Float.parseFloat(minlow.getText()); float newmaxhigh = Float.parseFloat(maxhigh.getText()); lowSlider.setAll(newminlow, newmaxhigh, lowSlider.getFloatValue()); highSlider.setAll(newminlow, newmaxhigh, highSlider.getFloatValue()); } } }; minlow.addKeyListener(limitsWatcher); maxhigh.addKeyListener(limitsWatcher); AdjustmentListener slider_watcher = new AdjustmentListener() { public void adjustmentValueChanged(AdjustmentEvent ae) { if (ae.getSource() == lowSlider) axis.setLow((float)lowSlider.getFloatValue()); else axis.setHigh((float)highSlider.getFloatValue()); } }; lowSlider.addAdjustmentListener(slider_watcher); highSlider.addAdjustmentListener(slider_watcher); frame.getContentPane().add(mainpanel); frame.setSize(new Dimension(1000, 400)); frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); frame.setVisible(true); } // end main }