sim.util.media.chart.ChartGenerator.java Source code

Java tutorial

Introduction

Here is the source code for sim.util.media.chart.ChartGenerator.java

Source

/*
  Copyright 2006 by Sean Luke and George Mason University
  Licensed under the Academic Free License version 3.0
  See the file "LICENSE" for more information
*/

package sim.util.media.chart;

import java.awt.*;
import java.awt.geom.*;
import java.awt.event.*;
import javax.swing.*;
import javax.swing.border.*;
import javax.swing.event.*;
import java.io.*;

import java.awt.image.*;

import sim.util.gui.*;
import sim.util.gui.Utilities;
import sim.util.*;
import sim.display.*;
import sim.util.media.*;

// From JFreeChart
import org.jfree.chart.*;
import org.jfree.chart.axis.*;
import org.jfree.chart.event.*;
import org.jfree.chart.plot.*;
import org.jfree.data.general.*;
import org.jfree.chart.title.*;
import org.jfree.data.xy.*;

// from iText (www.lowagie.com/iText/)
import com.lowagie.text.*;
import com.lowagie.text.pdf.*;

/*  // looks like we'll have to move to these soon
import com.itextpdf.text.*;
import com.itextpdf.text.pdf.*;
*/

/**
   ChartGenerator is a JPanel which displays a chart using the JFreeChart library.  The class is abstract:
   you'll need to use a concrete subclass to build a specific kind of chart.
   The facility allows multiple time series to be displayed at one time, to be exported to PDF,
   and to be dynamically added and removed.
       
   <p>Subclasses only really need to override one method: buildChart(), which creates the chart and the chartPanel and loads
   them into the chartHolder, then finally sets the series dataset.  Subclasses will also find it convenient to override
   update() to update the chart specially each time it's repainted, removeSeries(index) to remove a series, or moveSeries(index, boolean)
   to change the order of series.  In a subclass's constructor it may also modify the global attributes to make ones special
   to its kind of chart as well.  Finally, note that ChartGenerator has no standard API for <i>adding</i> a series to the chart, nor any standard way to modify
   this series once it has been added.  This is because JFreeChart has non-standard, non-consistent APIs for different
   kinds of charts.  You will need to implement these on a per-chart basis as you see fit.
       
   <p>ChartGenerator displays three regions:
       
   <p><ul>
   <li>The <tt>chart</tt> proper, stored in a <tt>chartPanel</tt>.  This panel is in turn stored in a JScrollPane.
   <li>The <tt>globalAttributes</tt>, a collection of Components on the top-left which control global features
   of the chart (its title, axis labels, etc.)
   <li>The <tt>seriesAttributes</tt>, a scrollable collection of Components on the bottom-left which control features
   of each separate series in the chart.
   </ul>
                
*/

public abstract class ChartGenerator extends JPanel {
    public void setChartPanel(ScrollableChartPanel chartPanel) {
        chartHolder.getViewport().setView(chartPanel);
        this.chartPanel = chartPanel;
    }

    /** A holder for global attributes components */
    protected Box globalAttributes = Box.createVerticalBox();
    /** A holder for series attributes components */
    protected Box seriesAttributes = Box.createVerticalBox();

    /** The chart */
    protected JFreeChart chart;
    /** The panel which holds and draws the chart */
    protected ScrollableChartPanel chartPanel;
    /** The JScrollPane which holds the ChartPanel */
    private JScrollPane chartHolder = new JScrollPane();

    JFrame frame;

    /** Returns the JFrame which stores the whole chart.  Set in createFrame(), else null. */
    public JFrame getFrame() {
        return frame;
    }

    /** The global attributes chart title field. */
    PropertyField titleField;

    NumberTextField scaleField;
    NumberTextField proportionField;
    JCheckBox fixBox;

    JButton movieButton = new JButton("Create Movie");
    BufferedImage buffer;

    public abstract Dataset getSeriesDataset();

    public abstract void setSeriesDataset(Dataset obj);

    protected void update() {
    }

    /** Override this to construct the appropriate kind of chart.  This is the first thing called from the constructor; so certain
    of your instance variables may not have been set yet and you may need to set them yourself.  You'll need to set the dataset. */
    protected abstract void buildChart();

    BufferedImage getBufferedImage() {
        // make a buffer
        if (buffer == null || buffer.getWidth(null) != chartPanel.getWidth()
                || buffer.getHeight(null) != chartPanel.getHeight()) {
            buffer = getGraphicsConfiguration().createCompatibleImage((int) chartPanel.getWidth(),
                    (int) chartPanel.getHeight());
        }

        // paint to the buffer
        Graphics2D g = (Graphics2D) (buffer.getGraphics());
        g.setColor(chartPanel.getBackground());
        g.fillRect(0, 0, buffer.getWidth(null), buffer.getHeight(null));
        chartPanel.paintComponent(g);
        g.dispose();
        return buffer;
    }

    MovieMaker movieMaker = null;

    static final long INITIAL_KEY = -1;
    public static final long FORCE_KEY = -2;
    long oldKey = INITIAL_KEY;

    /** Key must be 0 or higher.  Will update only if the key passed in is different
    from the previously passed in key or if the key is FORCE_KEY.  
    If newData is true, then the chart will also be written out to a movie if appropriate. */
    public void update(long key, boolean newData) {
        if (key == oldKey && key != FORCE_KEY) // we already did it
            return;
        else {
            oldKey = key;
            update();

            if (newData)
                chart.getPlot().datasetChanged(new DatasetChangeEvent(chart.getPlot(), null));

            // now possibly write to the movie maker
            if (newData && movieMaker != null) {
                // add buffer to the movie maker
                movieMaker.add(getBufferedImage());
            }
        }
    }

    void rebuildAttributeIndices() {
        SeriesAttributes[] c = getSeriesAttributes();
        for (int i = 0; i < c.length; i++) {
            SeriesAttributes csa = c[i];
            csa.setSeriesIndex(i);
            csa.rebuildGraphicsDefinitions();
        }
        revalidate();
    }

    protected SeriesAttributes getSeriesAttribute(int i) {
        return (SeriesAttributes) (seriesAttributes.getComponent(i));
    }

    public int getNumSeriesAttributes() {
        return seriesAttributes.getComponents().length;
    }

    protected SeriesAttributes[] getSeriesAttributes() {
        Component[] c = seriesAttributes.getComponents();
        SeriesAttributes[] sa = new SeriesAttributes[c.length];
        System.arraycopy(c, 0, sa, 0, c.length);
        return sa;
    }

    protected void setSeriesAttributes(SeriesAttributes[] c) {
        seriesAttributes.removeAll();
        for (int i = 0; i < c.length; i++) {
            seriesAttributes.add(c[i]);
        }
    }

    /** Override this to remove a series from the chart.  Be sure to call super(...) first. */
    public void removeSeries(int index) {
        // stop the inspector....
        SeriesAttributes[] c = getSeriesAttributes();
        SeriesChangeListener tmpObj = c[index].getStoppable();
        if (tmpObj != null) {
            tmpObj.seriesChanged(new SeriesChangeEvent(this));
        }

        // for good measure, set the index of the component to something crazy just in case a stoppable tries to continue pulsing it
        Component comp = seriesAttributes.getComponent(index);
        ((SeriesAttributes) comp).setSeriesIndex(-1);

        // remove the attribute and rebuild indices
        seriesAttributes.remove(index);
        rebuildAttributeIndices();
        revalidate();
    }

    /** Override this to move a series relative to other series.  Be sure to call super(...) first. */
    public void moveSeries(int index, boolean up) {
        if ((index > 0 && up) || (index < getSeriesCount() - 1 && !up)) // it's not the first or the last given the move
        {
            SeriesAttributes[] c = getSeriesAttributes();

            if (up) {
                SeriesAttributes s1 = c[index];
                SeriesAttributes s2 = c[index - 1];
                c[index] = s2;
                c[index - 1] = s1;
            } else {
                SeriesAttributes s1 = c[index];
                SeriesAttributes s2 = c[index + 1];
                c[index] = s2;
                c[index + 1] = s1;
            }
            setSeriesAttributes(c);
            rebuildAttributeIndices();
            revalidate();
        } else {
        } // ignore -- stupid user
    }

    /** Starts a Quicktime movie on the given ChartGenerator.  The size of the movie frame will be the size of
    the chart at the time this method is called.  This method ought to be called from the main event loop.
    Most of the default movie formats provided will result in a gigantic movie, which you can
    re-encode using something smarter (like the Animation or Sorenson codecs) to put to a reasonable size.
    On the Mac, Quicktime Pro will do this quite elegantly. */
    public void startMovie() {
        // can't start a movie if we're in an applet
        if (SimApplet.isApplet()) {
            Object[] options = { "Oops" };
            JOptionPane.showOptionDialog(this, "You cannot create movies from an applet.",
                    "MASON Applet Restriction", JOptionPane.OK_OPTION, JOptionPane.ERROR_MESSAGE, null, options,
                    options[0]);
            return;
        }

        if (movieMaker != null)
            return; // already running
        movieMaker = new MovieMaker(getFrame());

        if (!movieMaker.start(getBufferedImage()))
            movieMaker = null; // failed
        else {
            movieButton.setText("Stop Movie");

            // emit an image
            update(FORCE_KEY, true);
        }
    }

    /** Stops a Quicktime movie and cleans up, flushing the remaining frames out to disk. 
    This method ought to be called from the main event loop. */
    public void stopMovie() {
        if (movieMaker == null)
            return; // already stopped
        if (!movieMaker.stop()) {
            Object[] options = { "Drat" };
            JOptionPane.showOptionDialog(this,
                    "Your movie did not write to disk\ndue to a spurious JMF movie generation bug.",
                    "JMF Movie Generation Bug", JOptionPane.OK_OPTION, JOptionPane.WARNING_MESSAGE, null, options,
                    options[0]);
        }
        movieMaker = null;
        if (movieButton != null) // hasn't been destroyed yet
        {
            movieButton.setText("Create Movie");
        }
    }

    public abstract int getSeriesCount();

    /** Deletes all series from the chart. */
    public void removeAllSeries() {
        for (int x = getSeriesCount() - 1; x >= 0; x--)
            removeSeries(x);
    }

    /** Prepares the chart to be garbage collected.  If you override this, be sure to call super.quit() */
    public void quit() {
        if (movieMaker != null)
            movieMaker.stop();
        removeAllSeries();
    }

    /** Returns the ChartPanel holding the chart. */
    public ChartPanel getChartPanel() {
        return chartPanel;
    }

    /** Adds a global attribute panel to the frame */
    public void addGlobalAttribute(Component component) {
        globalAttributes.add(component);
    }

    /** Returns the global attribute panel of the given index. */
    public Component getGlobalAttribute(int index) {
        // at present we have a PDF button and a chart global panel --
        // then the global seriesAttributes start
        return globalAttributes.getComponent(index + 2);
    }

    /** @deprecated Use getNumGlobalAttributes */
    public int getGlobalAttributeCount() {
        return getNumGlobalAttributes();
    }

    /** Returns the number of global attribute panels. */
    public int getNumGlobalAttributes() {
        // at present we have a PDF button and a chart global panel --
        // then the global seriesAttributes start
        return globalAttributes.getComponentCount() - 2;
    }

    /** Remooves the global attribute at the given index and returns it. */
    public Component removeGlobalAttribute(int index) {
        Component component = getGlobalAttribute(index);
        globalAttributes.remove(index);
        return component;
    }

    /** This is set to a string indicating that the chart is invalid.  When the title
    is set in the chart, this title will be used instead. */
    protected String invalidChartTitle = null;
    protected String validChartTitle = "";

    /** Sets the invalid chart title if any.  If null,
    clears the invalid chart title and displays the
    actual chart title. */
    public void setInvalidChartTitle(String title) {
        invalidChartTitle = title;
        setTitle(validChartTitle);
    }

    /** Sets the title of the chart (and the window frame). 
    If there is an invalidChartTitle set, this is used
    instead and the specified title is held in storage
    to be used later.  */
    public void setTitle(String title) {
        validChartTitle = title;

        if (invalidChartTitle != null)
            title = invalidChartTitle;

        chart.setTitle(title);
        chart.titleChanged(new TitleChangeEvent(new org.jfree.chart.title.TextTitle(title)));
        if (frame != null)
            frame.setTitle(title);
        titleField.setValue(title);
    }

    /** Returns the title of the chart */
    public String getTitle() {
        return validChartTitle;
    }

    //static int uniqueNameKey = 0;
    //protected static String makeUniqueString(String name) { return name + (uniqueNameKey++); }

    /** Returns the underlying chart. **/
    public JFreeChart getChart() {
        return chart;
    }

    protected void buildGlobalAttributes(LabelledList list) {
    }

    /** Generates a new ChartGenerator with a blank chart.  Before anything else, buildChart() is called.  */
    public ChartGenerator() {
        // create the chart
        buildChart();
        chart.getPlot().setBackgroundPaint(Color.WHITE);
        chart.setAntiAlias(true);

        JSplitPane split = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, true);
        split.setBorder(new EmptyBorder(0, 0, 0, 0));
        JScrollPane scroll = new JScrollPane();
        JPanel b = new JPanel();
        b.setLayout(new BorderLayout());
        b.add(seriesAttributes, BorderLayout.NORTH);
        b.add(new JPanel(), BorderLayout.CENTER);
        scroll.getViewport().setView(b);
        scroll.setBackground(getBackground());
        scroll.getViewport().setBackground(getBackground());
        JPanel p = new JPanel();
        p.setLayout(new BorderLayout());

        LabelledList list = new LabelledList("Chart Properties");
        DisclosurePanel pan1 = new DisclosurePanel("Chart Properties", list);
        globalAttributes.add(pan1);

        JLabel j = new JLabel("Right-Click or Control-Click");
        j.setFont(j.getFont().deriveFont(10.0f).deriveFont(java.awt.Font.ITALIC));
        list.add(j);
        j = new JLabel("on Chart for More Options");
        j.setFont(j.getFont().deriveFont(10.0f).deriveFont(java.awt.Font.ITALIC));
        list.add(j);

        titleField = new PropertyField() {
            public String newValue(String newValue) {
                setTitle(newValue);
                getChartPanel().repaint();
                return newValue;
            }
        };
        titleField.setValue(chart.getTitle().getText());

        list.add(new JLabel("Title"), titleField);

        buildGlobalAttributes(list);

        final JCheckBox legendCheck = new JCheckBox();
        ItemListener il = new ItemListener() {
            public void itemStateChanged(ItemEvent e) {
                if (e.getStateChange() == ItemEvent.SELECTED) {
                    LegendTitle title = new LegendTitle(chart.getPlot());
                    title.setLegendItemGraphicPadding(new org.jfree.ui.RectangleInsets(0, 8, 0, 4));
                    chart.addLegend(title);
                } else {
                    chart.removeLegend();
                }
            }
        };
        legendCheck.addItemListener(il);
        list.add(new JLabel("Legend"), legendCheck);
        legendCheck.setSelected(true);

        /*
          final JCheckBox aliasCheck = new JCheckBox();
          aliasCheck.setSelected(chart.getAntiAlias());
          il = new ItemListener()
          {
          public void itemStateChanged(ItemEvent e)
          {
          chart.setAntiAlias( e.getStateChange() == ItemEvent.SELECTED );
          }
          };
          aliasCheck.addItemListener(il);
          list.add(new JLabel("Antialias"), aliasCheck);
        */

        JPanel pdfButtonPanel = new JPanel();
        pdfButtonPanel.setBorder(new javax.swing.border.TitledBorder("Chart Output"));
        DisclosurePanel pan2 = new DisclosurePanel("Chart Output", pdfButtonPanel);

        pdfButtonPanel.setLayout(new BorderLayout());
        Box pdfbox = new Box(BoxLayout.Y_AXIS);
        pdfButtonPanel.add(pdfbox, BorderLayout.WEST);

        JButton pdfButton = new JButton("Save as PDF");
        pdfbox.add(pdfButton);
        pdfButton.addActionListener(new ActionListener() {
            public void actionPerformed(ActionEvent e) {
                FileDialog fd = new FileDialog(frame, "Choose PDF file...", FileDialog.SAVE);
                fd.setFile(chart.getTitle().getText() + ".pdf");
                fd.setVisible(true);
                String fileName = fd.getFile();
                if (fileName != null) {
                    Dimension dim = chartPanel.getPreferredSize();
                    PDFEncoder.generatePDF(chart, dim.width, dim.height,
                            new File(fd.getDirectory(), Utilities.ensureFileEndsWith(fd.getFile(), ".pdf")));
                }
            }
        });
        movieButton = new JButton("Create a Movie");
        pdfbox.add(movieButton);
        pdfbox.add(Box.createGlue());
        movieButton.addActionListener(new ActionListener() {
            public void actionPerformed(ActionEvent e) {
                if (movieMaker == null)
                    startMovie();
                else
                    stopMovie();
            }
        });

        globalAttributes.add(pan2);

        // we add into an outer box so we can later on add more global seriesAttributes
        // as the user instructs and still have glue be last
        Box outerAttributes = Box.createVerticalBox();
        outerAttributes.add(globalAttributes);
        outerAttributes.add(Box.createGlue());

        p.add(outerAttributes, BorderLayout.NORTH);
        p.add(scroll, BorderLayout.CENTER);
        p.setMinimumSize(new Dimension(0, 0));
        p.setPreferredSize(new Dimension(200, 0));
        split.setLeftComponent(p);

        // Add scale and proportion fields
        Box header = Box.createHorizontalBox();

        final double MAXIMUM_SCALE = 8;

        fixBox = new JCheckBox("Fill");
        fixBox.addActionListener(new ActionListener() {
            public void actionPerformed(ActionEvent e) {
                setFixed(fixBox.isSelected());
            }
        });
        header.add(fixBox);
        fixBox.setSelected(true);

        // add the scale field
        scaleField = new NumberTextField("  Scale: ", 1.0, true) {
            public double newValue(double newValue) {
                if (newValue <= 0.0)
                    newValue = currentValue;
                if (newValue > MAXIMUM_SCALE)
                    newValue = currentValue;
                scale = newValue;
                resizeChart();
                return newValue;
            }
        };
        scaleField.setToolTipText("Zoom in and out");
        scaleField.setBorder(BorderFactory.createEmptyBorder(0, 0, 0, 2));
        scaleField.setEnabled(false);
        scaleField.setText("");
        header.add(scaleField);

        // add the proportion field
        proportionField = new NumberTextField("  Proportion: ", 1.5, true) {
            public double newValue(double newValue) {
                if (newValue <= 0.0)
                    newValue = currentValue;
                proportion = newValue;
                resizeChart();
                return newValue;
            }
        };
        proportionField.setToolTipText("Change the chart proportions (ratio of width to height)");
        proportionField.setBorder(BorderFactory.createEmptyBorder(0, 0, 0, 2));
        header.add(proportionField);

        chartHolder.setMinimumSize(new Dimension(0, 0));
        chartHolder.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
        chartHolder.setVerticalScrollBarPolicy(ScrollPaneConstants.VERTICAL_SCROLLBAR_ALWAYS);
        chartHolder.getViewport().setBackground(Color.gray);
        JPanel p2 = new JPanel();
        p2.setLayout(new BorderLayout());
        p2.add(chartHolder, BorderLayout.CENTER);
        p2.add(header, BorderLayout.NORTH);
        split.setRightComponent(p2);
        setLayout(new BorderLayout());
        add(split, BorderLayout.CENTER);

        // set the default to be white, which looks good when printed
        chart.setBackgroundPaint(Color.WHITE);

        // JFreeChart has a hillariously broken way of handling font scaling.
        // It allows fonts to scale independently in X and Y.  We hack a workaround here.
        chartPanel.setMinimumDrawHeight((int) DEFAULT_CHART_HEIGHT);
        chartPanel.setMaximumDrawHeight((int) DEFAULT_CHART_HEIGHT);
        chartPanel.setMinimumDrawWidth((int) (DEFAULT_CHART_HEIGHT * proportion));
        chartPanel.setMaximumDrawWidth((int) (DEFAULT_CHART_HEIGHT * proportion));
        chartPanel.setPreferredSize(new java.awt.Dimension((int) (DEFAULT_CHART_HEIGHT * DEFAULT_CHART_PROPORTION),
                (int) (DEFAULT_CHART_HEIGHT)));
    }

    public boolean isFixed() {
        return fixBox.isSelected();
    }

    public void setFixed(boolean value) {
        fixBox.setSelected(value);
        chartHolder.setHorizontalScrollBarPolicy(value ? ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
                : ScrollPaneConstants.HORIZONTAL_SCROLLBAR_ALWAYS);
        scaleField.setEnabled(!value);
        if (value)
            scaleField.setText("");
        else {
            double val = scaleField.getValue();
            if (val == (int) val)
                scaleField.setText("" + (int) val);
            else
                scaleField.setText("" + val);
        }
        resizeChart();
    }

    public double DEFAULT_CHART_HEIGHT = 480;
    public double DEFAULT_CHART_PROPORTION = 1.5;

    double scale = 1.0;
    double proportion = 1.5;

    public double getScale() {
        return scale;
    }

    public double getProportion() {
        return proportion;
    }

    public void setScale(double val) {
        scale = val;
        scaleField.setValue(val);
        resizeChart();
    }

    public void setProportion(double val) {
        proportion = val;
        proportionField.setValue(val);
        resizeChart();
    }

    void resizeChart() {
        double w = DEFAULT_CHART_HEIGHT * scale * proportion;
        double h = DEFAULT_CHART_HEIGHT * scale;
        Dimension d = new java.awt.Dimension((int) (w), (int) (h));

        chartPanel.setSize(new java.awt.Dimension(d));
        chartPanel.setPreferredSize(chartPanel.getSize());

        // JFreeChart has a hillariously broken way of handling font scaling.
        // It allows fonts to scale independently in X and Y.  We hack a workaround
        // here.
        chartPanel.setMinimumDrawHeight((int) DEFAULT_CHART_HEIGHT);
        chartPanel.setMaximumDrawHeight((int) DEFAULT_CHART_HEIGHT);
        chartPanel.setMinimumDrawWidth((int) (DEFAULT_CHART_HEIGHT * proportion));
        chartPanel.setMaximumDrawWidth((int) (DEFAULT_CHART_HEIGHT * proportion));

        chartPanel.repaint();
    }

    /** Returns a JFrame suitable or housing the ChartGenerator.  This frame largely calls chart.quit() when
    the JFrame is being closed.  By default the JFrame will HIDE itself (not DISPOSE itself) when closed.  */
    public JFrame createFrame() {
        return createFrame(false);
    }

    /** Returns a JFrame suitable or housing the ChartGenerator.  This frame largely calls chart.quit() when
    the JFrame is being closed.  By default the JFrame will HIDE itself (not DISPOSE itself) when closed. 
    If inspector == true, the frame will have the look of an inspector */
    public JFrame createFrame(boolean inspector) {
        frame = new JFrame() {
            public void dispose() {
                quit();
                super.dispose();
            }
        };
        if (inspector)
            frame.getRootPane().putClientProperty("Window.style", "small"); // on the Mac

        frame.setDefaultCloseOperation(WindowConstants.HIDE_ON_CLOSE);
        frame.getContentPane().setLayout(new BorderLayout());
        frame.getContentPane().add(this, BorderLayout.CENTER);
        frame.setResizable(true);
        frame.pack();
        frame.setTitle(chart.getTitle().getText());
        return frame;
    }

    /** @deprecated use createFrame() */
    public JFrame createFrame(Object simulation) {
        return createFrame();
    }

    static {
        // quaquaify
        try {
            String version = System.getProperty("java.version");
            // both of the following will generate exceptions if the class doesn't exist, so we're okay
            // trying to put them in the UIManager here
            if (version.startsWith("1.3")) {
                // broken
                //UIManager.put("ColorChooserUI", 
                //      Class.forName("ch.randelshofer.quaqua.Quaqua13ColorChooserUI").getName());
            } else // hope there's no one using 1.2! 
                UIManager.put("ColorChooserUI", Class.forName("ch.randelshofer.quaqua.Quaqua14ColorChooserUI", true,
                        Thread.currentThread().getContextClassLoader()).getName());
        } catch (Exception e) {
        }
    }

    /** Add a legend to the chart unless the chart already has one. **/
    public void addLegend() {
        if (chart.getLegend() != null) // don't do anything if there already is one
            return;

        LegendTitle title = new LegendTitle(chart.getPlot());
        title.setLegendItemGraphicPadding(new org.jfree.ui.RectangleInsets(0, 8, 0, 4));
        chart.addLegend(title);
    }

    Thread timer = null;

    /** Updates the inspector asynchronously sometime before the given milliseconds have transpired.  Once
    requested, further calls to request an update via this method will be ignored until the update occurs. */
    public void updateChartWithin(final long key, final long milliseconds) {
        if (timer == null) {
            timer = sim.util.gui.Utilities.doLater(milliseconds, new Runnable() {
                public void run() {
                    update(key, true); // keep up-to-date
                    // this is in the Swing thread, so it's okay
                    timer = null;
                }
            });
        }
    }

    /** Posts a request to update the chart on the Swing event queue to happen next time repaints etc. happen. */
    public void updateChartLater(final long key) {
        repaint(); // make sure a repaint happens first  -- this is probably unnecessary

        javax.swing.SwingUtilities.invokeLater(new Runnable() {
            public void run() {
                update(key, true);
            }
        });
    }

    static int DEFAULT_UNIT_FRACTION = 20;
    static int DEFAULT_BLOCK_FRACTION = 2;

    class ScrollableChartPanel extends ChartPanel implements Scrollable {
        public ScrollableChartPanel(JFreeChart chart, boolean useBuffer) {
            super(chart, useBuffer);
        }

        public Dimension getPreferredSize() {
            Dimension size = super.getPreferredSize();
            int viewportWidth = chartHolder.getViewport().getWidth();
            if (viewportWidth == 0) // uh oh, not set up yet
                return size;

            // adjust height
            if (isFixed())
                size.height = (int) (size.height / (double) size.width * viewportWidth);
            return size;
        }

        public Dimension getPreferredScrollableViewportSize() {
            return getPreferredSize();
        }

        public int getScrollableUnitIncrement(java.awt.Rectangle visibleRect, int orientation, int direction) {
            return (int) ((orientation == SwingConstants.HORIZONTAL)
                    ? (visibleRect.getWidth() / DEFAULT_UNIT_FRACTION)
                    : (visibleRect.getHeight() / DEFAULT_UNIT_FRACTION));
        }

        public int getScrollableBlockIncrement(java.awt.Rectangle visibleRect, int orientation, int direction) {
            return (int) ((orientation == SwingConstants.HORIZONTAL)
                    ? (visibleRect.getWidth() / DEFAULT_BLOCK_FRACTION)
                    : (visibleRect.getHeight() / DEFAULT_BLOCK_FRACTION));
        }

        public boolean getScrollableTracksViewportHeight() {
            return false;
        }

        public boolean getScrollableTracksViewportWidth() {
            return isFixed();
        }

        public Dimension getMaximumSize() {
            return getPreferredSize();
        }

        public Dimension getMinimumSize() {
            return getPreferredSize();
        }

        public void setSize(Dimension d) {
            super.setSize(d);
        }
    }

    public ScrollableChartPanel buildChartPanel(JFreeChart chart) {
        return new ScrollableChartPanel(chart, true);
    }

    // This ridiculous class exists so we can create Strings (of sorts) which are completely
    // uncomparable and have a total sort order regardless of their values.  Otherwise
    // (this is true) MultiplePiePlot won't allow multiple PiePlots with the same name.
    public static class UniqueString implements java.lang.Comparable {
        String string;

        public UniqueString(Object obj) {
            string = "" + obj;
        }

        public boolean equals(Object obj) {
            return obj == this;
        }

        public int compareTo(Object obj) {
            if (obj == this)
                return 0;
            if (obj == null)
                throw new NullPointerException();
            if (!(obj instanceof UniqueString))
                return -1;
            UniqueString us = (UniqueString) obj;
            if (us.string.equals(string)) // gotcha.  Gotta differentiate
            {
                if (System.identityHashCode(this) > System.identityHashCode(us))
                    return 1;
                else
                    return -1;
            } else
                return us.string.compareTo(string);
        }

        public String toString() {
            return string;
        }
    }
}