Search code examples
javagraphics2d

Trouble understanding Graphics.getClipBounds() and viewports


I was trying to understand viewports better so I made a vertical timebar that can be placed in a JScrollPane's row header view. It works, but when I investigated further it appears when scrolling down it draws areas of the component that aren't visible. I only expected it to draw the visible area based on Graphics.getClipBounds(), but as I continue to scroll down it draws more and more of the component until at the bottom the printouts indicate I'm drawing the full height of the component.

It seems like there is something wrong with my calculation of topMillis, but endMillis (which is based on topMillis) looks correct.

To see the problem run the program and note the difference between topMillis and endMillis increases while scrolling down. I expected the difference to remain the same, as that should be the area that is visible to the user.

On a side note is there an even more efficient way to draw this component? The simple way would be to draw the entire component every time. That gets prohibitive if the time range gets too big. The way I'm doing it should be more efficient in that we only draw what is visible to the user regardless of how big the time range gets. But my approach is tied directly to the size of the data panel which seems problematic. Is there a way I can make my component be just the size of the row header viewport, but still accurately reflect what the user is scrolling in the scroll pane?

import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.Rectangle;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.concurrent.TimeUnit;

import javax.swing.BorderFactory;
import javax.swing.JButton;
import javax.swing.JComponent;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.ScrollPaneConstants;
import javax.swing.SwingConstants;
import javax.swing.SwingUtilities;

public class TimeBarTest {

   private static final int DATA_HEIGHT = 1000;

   private final JScrollPane mScrollPane;

   private TimeBar mRowHeaderView;

   TimeBarTest() {

      JPanel dataPanel = new JPanel();
      dataPanel.setPreferredSize(new Dimension(0, DATA_HEIGHT));

      mScrollPane = new JScrollPane(dataPanel);
      mScrollPane.setHorizontalScrollBarPolicy(JScrollPane.HORIZONTAL_SCROLLBAR_NEVER);
      mScrollPane.setVerticalScrollBarPolicy(JScrollPane.VERTICAL_SCROLLBAR_ALWAYS);

      JButton cornerButton = new JButton("Hi");
      cornerButton.addActionListener(pEvent -> {
         mRowHeaderView.setTime(System.currentTimeMillis());
         mScrollPane.getVerticalScrollBar().setValue(0);
      });
      cornerButton.setPreferredSize(new Dimension(20, 20));

      JPanel columnHeader = new JPanel(new BorderLayout());
      columnHeader.setPreferredSize(new Dimension(0, 30));
      columnHeader.setBorder(BorderFactory.createLineBorder(Color.GRAY));
      columnHeader.add(new JLabel("Column Header", SwingConstants.CENTER), BorderLayout.CENTER);

      mRowHeaderView = new TimeBar(DATA_HEIGHT);

      mScrollPane.setCorner(ScrollPaneConstants.UPPER_LEFT_CORNER, cornerButton);
      mScrollPane.setRowHeaderView(mRowHeaderView);
      mScrollPane.setColumnHeaderView(columnHeader);

      JPanel contentPane = new JPanel(new BorderLayout());
      contentPane.setPreferredSize(new Dimension(500, 475));
      contentPane.add(mScrollPane, BorderLayout.CENTER);

      JFrame frame = new JFrame();
      frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
      frame.setContentPane(contentPane);

      frame.pack();
      frame.setLocationRelativeTo(null);
      frame.setVisible(true);
   }

   public static void main(String... args) {
      SwingUtilities.invokeLater(() -> new TimeBarTest());
   }

   private class TimeBar extends JComponent {

      private final int MAJOR_TICK_LENGTH = 8;
      private final int MINOR_TICK_LENGTH = 4;

      private final int MINUTES_PER_MAJOR = 5;
      private final int MINUTES_PER_MINOR = 1;
      private final long MILLIS_PER_PIXEL = 4000;

      private final long MILLIS_PER_MAJOR = TimeUnit.MINUTES.toMillis(MINUTES_PER_MAJOR);
      private final long MILLIS_PER_MINOR = TimeUnit.MINUTES.toMillis(MINUTES_PER_MINOR);

      private final SimpleDateFormat mSimpleDateFormat = new SimpleDateFormat("hh:mm");

      private long mTime;

      TimeBar(int pHeight) {
         setPreferredSize(new Dimension(50, pHeight));

         mTime = System.currentTimeMillis();
      }

      public void setTime(long pTime) {
         mTime = pTime;
         repaint();
      }

      @Override
      protected void paintComponent(Graphics pGraphics) {
         super.paintComponent(pGraphics);

         pGraphics.setColor(Color.black);

         Rectangle clipBounds = pGraphics.getClipBounds();
         Rectangle visibleRect = getVisibleRect();

         // Determine the start and end time based on the visible area.
         long topMillis = mTime - (clipBounds.y * MILLIS_PER_PIXEL);
         long endMillis = topMillis - ((clipBounds.y + clipBounds.height) * MILLIS_PER_PIXEL);

         // Determine where we should start drawing the ticks.
         long startMillis = topMillis - (topMillis % MILLIS_PER_MINOR);

         System.out.println("    clipBounds=" + clipBounds);
         System.out.println("   visibleRect=" + visibleRect);
         SimpleDateFormat dateFormat = new SimpleDateFormat("hh:mm:ss");
         System.out.println("      origTime=" + dateFormat.format(new Date(mTime)));
         System.out.println("       topTime=" + dateFormat.format(new Date(topMillis)));
         System.out.println("   startMillis=" + dateFormat.format(new Date(startMillis)));
         System.out.println("       endTime=" + dateFormat.format(new Date(endMillis)));
         System.out.println("     topMillis=" + topMillis);
         System.out.println("   startMillis=" + startMillis);
         System.out.println("     endMillis=" + endMillis);
         System.out.println("millisPerMajor=" + MILLIS_PER_MAJOR);
         System.out.println("millisPerMinor=" + MILLIS_PER_MINOR);

         // Draw the ticks and labels backwards through time.
         for (long i = startMillis; i >= endMillis; i -= MILLIS_PER_MINOR) {
            int pixel = (int) ((topMillis - i) / MILLIS_PER_PIXEL);
            System.out.println("pixel=" + pixel);

            if (i % MILLIS_PER_MAJOR == 0) {
               String text = mSimpleDateFormat.format(new Date(i));
               pGraphics.drawString(text, 1, (int) (pixel + 4));
               pGraphics.drawLine(clipBounds.width, (int) pixel, clipBounds.width - MAJOR_TICK_LENGTH, (int) pixel);
            } else {
               pGraphics.drawLine(clipBounds.width, (int) pixel, clipBounds.width - MINOR_TICK_LENGTH, (int) pixel);
            }
         }
      }
   }
}

Solution

  • I think the first issue, is just an algebraic one.

    The starting time you will display is mTime - clipBounds.y*MILLIS_PER_PIXEL, and the last time is mTime - (clipBounds.y + clipBounds.height)*MILLIS_PER_PIXEL. If you want to write that as a function of the start time.

    endMillis = topMillis - clipBounds.height*MILLIS_PER_PIXEL;
    

    The next issue is slightly more fundamental. You don't want to change your drawing based on where the viewport is. Essentially, you would draw the whole scene, but as you said, the time could be incredibly long you can do some manual clipping.

    So your coordinate will be based on the mTime and not the start time.

    int pixel = (int) ((mTime-i) / MILLIS_PER_PIXEL);
    

    If you weren't clipping, you would go from mTime all the way to the end oftime, and this wouldn't change.