package com.superliminal.uiutil;
import javax.swing.*;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.Calendar;
import java.util.Arrays;
import java.awt.*;
import java.awt.geom.Rectangle2D;
import java.awt.event.ActionListener;
import java.awt.event.ActionEvent;
import java.text.DateFormat;
/**
* Presents two or more selectable dates arrayed on a timeline.<br>
*
* Created Apr 28, 2006
*
* @author Melinda Green
*/
public class TimePicker extends JPanel {
private final static String MONSTRS[] = {
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"
};
private final static int PAD=30, BW=20, DAYTIC=8;
private final static String FAMILY = "Sans Serif";
private final static Font
NONTH_FONT = new Font(FAMILY, Font.BOLD, 14),
DAY_FONT = new Font(FAMILY, Font.BOLD, 12),
HOUR_FONT = new Font(FAMILY, Font.PLAIN, 10);
private Date[] dates;
private JRadioButton butts[];
public static interface SelectionListener {
public void selectionChanged(int selection);
}
/**
* Convenience constructor assumes no initial selection or selection listener.
*/
public TimePicker(Date[] dates) {
this(dates, -1, null);
}
public Dimension getMinimumSize() {
return new Dimension(2*PAD, 2*PAD);
}
/**
* @param dates must have 2 or more entries sorted from newest to oldest.
* @param initialSelection index of date to initially select or -1 if none.
* @param dl optional callback object notified whenever selection changes.
*/
public TimePicker(Date[] dates, int initialSelection, final SelectionListener dl) {
this.dates = dates;
this.setPreferredSize(new Dimension(300, 70));
this.setLayout(null); // because we will position the buttons explicitly
ActionListener selectionWatcher = new ActionListener() {
public void actionPerformed(ActionEvent e) {
if(dl != null)
dl.selectionChanged(getSelection());
}
};
ButtonGroup group = new ButtonGroup();
butts = new JRadioButton[dates.length];
for(int i=0; i<butts.length; i++) {
JRadioButton butt = new JRadioButton();
butts[i] = butt;
butt.setSize(BW, BW);
butt.setOpaque(false);
butt.setToolTipText(dates[i].toString());
group.add(butt);
butt.addActionListener(selectionWatcher);
butt.setSelected(i == initialSelection);
this.add(butt);
}
}
/**
* @return the index of the currently selected date or -1 if none.
*/
public int getSelection() {
for(int i=0; i<butts.length; i++)
if(butts[i].isSelected())
return i;
return -1;
}
/**
* Positions all the radio buttons.
*/
public void doLayout() {
int w = getWidth(), h = getHeight();
float spanInMillis = dates[0].getTime() - dates[dates.length-1].getTime();
float pixelsPerMilli = (w-2*PAD) / spanInMillis;
// first arrange them all in a line ignoring overlaps
for(int i=0; i<butts.length; i++) {
float offset = (dates[0].getTime() - dates[i].getTime()) * pixelsPerMilli;
butts[i].setLocation((int)(w-PAD-offset-BW/2), h-2*PAD);
}
// next, make a pass looking for overlaps and adjust vertically when that fixes them, otherwise too bad.
int availableVerticalSpace = butts[0].getY() + butts[0].getHeight();
int minx[] = new int[availableVerticalSpace/BW]; // leftmost edges for each row.
Arrays.fill(minx, Integer.MAX_VALUE);
for (JRadioButton cur : butts) { // place each button
int rightEdge = cur.getX() + cur.getWidth();
for (int row = 0; row < minx.length; row++) { // counting up from base row
if (rightEdge < minx[row]) {
minx[row] = cur.getX(); // set the new minimum for this row
cur.setLocation(cur.getX(), cur.getY() - row * BW);
break; // go to positioning next button
}
}
}
}
public void paintComponent(Graphics g) {
Font origFont = g.getFont();
int w = getWidth(), h = getHeight();
g.setColor(getBackground());
g.fillRect(0, 0, w, h);
float spanInMillis = dates[0].getTime() - dates[dates.length-1].getTime();
float pixelsPerMilli = (w-2*PAD) / spanInMillis;
GregorianCalendar monthStart = round(dates[0], Calendar.MONTH);
monthStart.add(Calendar.MONDAY, 1);
int xend = w;
Color bg = getBackground();
Color fg = Color.black;
Color other = StaticUtils.slightlyDifferentColor(bg);
g.setFont(NONTH_FONT);
boolean even = true;
while(xend > 0) {
g.setColor(other);
even = !even;
float offset = (dates[0].getTime() - monthStart.getTimeInMillis()) * pixelsPerMilli;
int xst = (int)(w-PAD-offset);
if(even)
g.fillRect(xst,0,xend-xst,h);
g.setColor(fg);
g.drawString(MONSTRS[monthStart.get(Calendar.MONTH)], xst+3, h-3);
xend = xst;
monthStart.add(Calendar.MONDAY, -1);
}
g.setColor(fg);
// vertical line showing "now"
//int now = w-PAD-(int)((dates[0].getTime() - System.currentTimeMillis()) * pixelsPerMilli);
//g.drawLine(now, h-PAD, now, 20);
//g.drawString("now", now-width("now",g)/2, g.getFontMetrics().getAscent());
// horizontal time axis
g.drawLine(0, h-PAD, w, h-PAD);
// for short time spans, draw tick marks for days and possibly hours.
float pixelsPerMinute = pixelsPerMilli * 1000 * 60;
float pixelsPerHour = pixelsPerMinute * 60;
float pixelsPerDay = pixelsPerHour * 24;
if(pixelsPerDay > 0) {
GregorianCalendar midnight = round(dates[dates.length-1], Calendar.DAY_OF_MONTH);
GregorianCalendar end = new GregorianCalendar();
end.setTime(dates[0]);
while(midnight.before(end)) {
int daytic = PAD+(int)((midnight.getTimeInMillis() - dates[dates.length-1].getTime()) * pixelsPerMilli);
g.fillRect(daytic, h-PAD-DAYTIC, 2, DAYTIC);
if(pixelsPerDay > 18) { // enough room so day-of-month numbers don't overlap?
String daystr = ""+midnight.get(Calendar.DAY_OF_MONTH);
if(pixelsPerDay > 35) // enough room for month number too?
daystr = (midnight.get(Calendar.MONTH)+1) + "/" + daystr;
g.setFont(DAY_FONT);
g.drawString(daystr, daytic-width(daystr,g)/2, h-PAD+12);
g.setFont(HOUR_FONT);
if(pixelsPerHour > 5) { // enough room for hour data too?
for(int hour=1; hour<24; hour++) {
int hx = (int)(daytic+hour*pixelsPerHour+.5);
if(pixelsPerHour > 18) { // enough room so hour-of-day numbers don't overlap?
String hourstr = ""+hour;
g.drawString(hourstr, hx-width(hourstr,g)/2, h-PAD-2);
if(pixelsPerMinute > 5) { // enough room for minute data too?
for(int minute=1; minute<60; minute++) {
int mx = (int)(hx+minute*pixelsPerMinute+.5);
String minutestr = ""+minute;
if(pixelsPerMinute > 20) { // enough room so minute numbers don't overlap?
if(pixelsPerMinute > 35)
minutestr = hourstr + ':' + minutestr; // yes, and room for hour too
g.drawString(minutestr, mx-width(minutestr,g)/2, h-PAD-2);
}
else // no, only enough room for a minute tic mark
g.drawLine(mx, h-PAD, mx, h-PAD-DAYTIC/3);
}
}
}
else // no, only enough room for an hour tic mark
g.drawLine(hx, h-PAD, hx, h-PAD-DAYTIC/2);
}
}
}
midnight.add(Calendar.DAY_OF_MONTH, 1);
}
// paint labels in the lower corners with the dates of the first and last dates in the range
g.setFont(HOUR_FONT); // because it's a good small font
DateFormat df = DateFormat.getDateInstance();
StaticUtils.fillString(df.format(dates[dates.length-1]), 3, h-3, bg, g);
String date0 = df.format(dates[0]);
Rectangle2D strrect = g.getFontMetrics().getStringBounds(date0, null);
StaticUtils.fillString(date0, w-(int)strrect.getWidth()-3, h-3, bg, g);
g.setFont(origFont);
}
}
private static int width(String str, Graphics g) {
return (int)(g.getFontMetrics().getStringBounds(str, null).getWidth()+.5);
}
/**
* Rounds a given date to a specified unit.
* @param date date to round
* @param unit one of Calendar.YEAR, Calendar.MONTH, Calendar.DAY_OF_MONTH, Calendar.HOUR_OF_DAY, Calendar.MINUTE, Calendar.SECOND.
* @return date rounded to the nearest specified unit.
*/
public static GregorianCalendar round(Date date, int unit) {
GregorianCalendar cal = new GregorianCalendar();
cal.setTime(date);
switch(unit) {
case Calendar.YEAR: return
new GregorianCalendar(cal.get(Calendar.YEAR), 0, 1);
case Calendar.MONTH: return
new GregorianCalendar(cal.get(Calendar.YEAR), cal.get(Calendar.MONTH), 1);
case Calendar.DAY_OF_MONTH: return
new GregorianCalendar(cal.get(Calendar.YEAR), cal.get(Calendar.MONTH), cal.get(Calendar.DAY_OF_MONTH));
case Calendar.HOUR_OF_DAY: return
new GregorianCalendar(cal.get(Calendar.YEAR), cal.get(Calendar.MONTH), cal.get(Calendar.DAY_OF_MONTH), cal.get(Calendar.HOUR_OF_DAY), 0);
case Calendar.MINUTE: return
new GregorianCalendar(cal.get(Calendar.YEAR), cal.get(Calendar.MONTH), cal.get(Calendar.DAY_OF_MONTH), cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE));
case Calendar.SECOND: return
new GregorianCalendar(cal.get(Calendar.YEAR), cal.get(Calendar.MONTH), cal.get(Calendar.DAY_OF_MONTH), cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE), 0);
}
return null;
}
/**
* A simple example test program.
*/
public static void main(String[] args) {
JFrame frame = new StaticUtils.QuickFrame("TimePicker Test");
frame.setSize(600, 154);
long now = System.currentTimeMillis();
long daylen = 1000 * 60 * 60 * 24;
frame.add(new TimePicker(
//new Date[] {new Date(now), new Date(now-daylen/4), new Date(now-daylen*2)},
new Date[] {
new Date(now), new Date(now-daylen/4), new Date(now-daylen/3), new Date(now-daylen/2), new Date(now-daylen*2),
new Date(now-daylen*8), new Date(now-daylen*8-daylen/2), new Date(now-daylen*8-daylen),
new Date(now-daylen*20)
},
0,
new TimePicker.SelectionListener() {
public void selectionChanged(int selection) {
System.out.println("Selected date " + selection);
}
}
));
frame.setVisible(true);
}
}