// 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
}