/*
* Copyright (C) 2011 Andrea Schweer
*
* This file is part of the Digital Parrot.
*
* The Digital Parrot is free software; you can redistribute it and/or modify
* it under the terms of the Eclipse Public License as published by the Eclipse
* Foundation or its Agreement Steward, either version 1.0 of the License, or
* (at your option) any later version.
*
* The Digital Parrot is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the Eclipse Public License for
* more details.
*
* You should have received a copy of the Eclipse Public License along with the
* Digital Parrot. If not, see http://www.eclipse.org/legal/epl-v10.html.
*
*/
package net.schweerelos.timeline.ui;
import java.awt.BasicStroke;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.Font;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Paint;
import java.awt.RenderingHints;
import java.awt.event.ActionEvent;
import java.awt.event.ComponentAdapter;
import java.awt.event.ComponentEvent;
import java.awt.event.KeyEvent;
import java.awt.event.MouseEvent;
import java.beans.PropertyChangeEvent;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javax.swing.AbstractAction;
import javax.swing.Action;
import javax.swing.DefaultListSelectionModel;
import javax.swing.ImageIcon;
import javax.swing.JPanel;
import javax.swing.ListSelectionModel;
import javax.swing.ToolTipManager;
import javax.swing.event.ListSelectionEvent;
import javax.swing.event.ListSelectionListener;
import javax.swing.event.MouseInputAdapter;
import net.schweerelos.parrot.timeline.IntervalListener;
import net.schweerelos.timeline.model.IntervalChain;
import net.schweerelos.timeline.model.PayloadInterval;
import net.schweerelos.timeline.model.Timeline;
import org.apache.log4j.Logger;
import org.joda.time.DateTime;
import org.joda.time.Duration;
import org.joda.time.Interval;
public class TimelinePanel<T extends Object> extends JPanel {
private static final float ZOOM_FACTOR = 3.0f;
private static final long serialVersionUID = 1L;
private ListSelectionModel selectionModel;
private AbstractAction zoomOutAction;
private AbstractAction zoomInAction;
private Logger logger;
private Map<ColorKeys, Color> colors;
private Timeline<T> tModel;
private static final Map<ColorKeys, Color> DEFAULT_COLORS = new HashMap<ColorKeys, Color>();
static {
DEFAULT_COLORS.put(ColorKeys.Background, Color.WHITE);
DEFAULT_COLORS.put(ColorKeys.Label, new Color(0x6292E4));
DEFAULT_COLORS.put(ColorKeys.LabelOdd, DEFAULT_COLORS
.get(ColorKeys.Label));
DEFAULT_COLORS.put(ColorKeys.BackgroundOdd, new Color(0xF2FAFC));
DEFAULT_COLORS.put(ColorKeys.SelectedOutline, new Color(0xFFCB77));
DEFAULT_COLORS.put(ColorKeys.IntervalFill, new Color(0xfff9e9));
DEFAULT_COLORS.put(ColorKeys.IntervalOutline, new Color(0xf7d891));
DEFAULT_COLORS.put(ColorKeys.HistogramFill, new Color(0xbacbce));
}
private static final int INTERVAL_HEIGHT = 12;
private static final int MINIMUM_H_GAP = 2;
private static final int V_GAP = 5;
private static final int HISTOGRAM_HEIGHT = 12;
private static final int SHORTEST_VISIBLE_INTERVAL = 3;
public TimelinePanel() {
this(DEFAULT_COLORS);
}
public TimelinePanel(Map<ColorKeys, Color> colors) {
super();
logger = Logger.getLogger(TimelinePanel.class);
this.colors = colors;
setLayout(new TimelineLayout(MINIMUM_H_GAP, V_GAP, INTERVAL_HEIGHT, HISTOGRAM_HEIGHT));
setOpaque(true);
setBackground(colors.get(ColorKeys.Background));
ToolTipManager.sharedInstance().registerComponent(this);
selectionModel = new DefaultListSelectionModel();
selectionModel
.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION);
MouseInputAdapter mouseAdapter = new MouseInputAdapter() {
private boolean dragging;
private int lastDraggedOverSlice;
@Override
public void mousePressed(MouseEvent e) {
selectionModel.setValueIsAdjusting(true);
int slice = convertXCoordToRow(e.getX());
if (selectionModel.isSelectedIndex(slice)) {
return;
}
if (!e.isShiftDown() && !e.isControlDown()) {
// "naked" click -> start selection process from scratch
selectionModel.clearSelection();
}
if (selectionModel.isSelectionEmpty()) {
// empty selection -> start a new selection
selectionModel.setSelectionInterval(slice, slice);
} else if (e.isControlDown()) {
// ctrl -> toggle selection of selected slice
int anchor = selectionModel.getAnchorSelectionIndex();
if (selectionModel.isSelectedIndex(slice)) {
selectionModel.removeSelectionInterval(slice, slice);
} else {
selectionModel.addSelectionInterval(slice, slice);
}
selectionModel.setAnchorSelectionIndex(anchor);
} else if (e.isShiftDown()) {
// shift -> select from anchor (first clicked cell) to
// current
int anchor = selectionModel.getAnchorSelectionIndex();
selectionModel.addSelectionInterval(anchor, slice);
selectionModel.setAnchorSelectionIndex(anchor);
}
repaint();
}
@Override
public void mouseReleased(MouseEvent e) {
if (!dragging) {
return; // ignore
}
selectionModel.setValueIsAdjusting(false);
dragging = false;
}
@Override
public void mouseClicked(MouseEvent e) {
selectionModel.setValueIsAdjusting(false);
if (e.getClickCount() == 2) {
zoomToSelection();
}
}
@Override
public void mouseDragged(MouseEvent e) {
int slice = convertXCoordToRow(e.getX());
if (e.isControlDown() || e.isShiftDown()
|| slice == lastDraggedOverSlice) {
return; // ignore
}
lastDraggedOverSlice = slice;
dragging = true;
int anchor = selectionModel.getAnchorSelectionIndex();
if (slice < anchor) {
// selection is between start and anchor
selectionModel.setSelectionInterval(slice, anchor);
selectionModel.setAnchorSelectionIndex(anchor);
} else {
// selection is between anchor and end
selectionModel.setSelectionInterval(anchor, slice);
selectionModel.setAnchorSelectionIndex(anchor);
}
repaint();
}
};
addMouseListener(mouseAdapter);
addMouseMotionListener(mouseAdapter);
zoomOutAction = new AbstractAction("Zoom out", new ImageIcon(
"images/zoom-out.png")) {
private static final long serialVersionUID = 1L;
@Override
public void actionPerformed(ActionEvent e) {
// add a (zoom factor) proportion of the current interval at the
// beginning
// and add another one at the end
long seconds = tModel.getDuration().getStandardSeconds();
int difference = (int) Math.ceil(seconds / ZOOM_FACTOR);
DateTime newStart = tModel.getStart().minusSeconds(difference);
DateTime newEnd = tModel.getEnd().plusSeconds(difference);
tModel.setInterval(newStart, newEnd);
}
};
zoomOutAction.putValue(Action.MNEMONIC_KEY, KeyEvent.VK_O);
zoomOutAction.putValue(Action.DISPLAYED_MNEMONIC_INDEX_KEY, 5);
zoomInAction = new AbstractAction("Zoom in", new ImageIcon(
"images/zoom-in.png")) {
private static final long serialVersionUID = 1L;
@Override
public void actionPerformed(ActionEvent e) {
if (!selectionModel.isSelectionEmpty()) {
zoomToSelection();
} else {
// otherwise:
// remove a (zoom factor) proportion of the current interval at
// the beginning and remove another one at the end
zoomIn();
}
}
};
zoomInAction.putValue(Action.MNEMONIC_KEY, KeyEvent.VK_I);
selectionModel.addListSelectionListener(new ListSelectionListener() {
@Override
public void valueChanged(ListSelectionEvent e) {
if (!e.getValueIsAdjusting()) {
zoomInAction.setEnabled(canZoomInFurther());
}
}
});
clearAll();
addComponentListener(new ComponentAdapter() {
@Override
public void componentResized(ComponentEvent e) {
adjustVisibleIntervals();
}
});
}
@Override
public String getToolTipText() {
return "Timeline";
}
@Override
public String getToolTipText(MouseEvent event) {
try {
int slice = event.getX() / calculateSliceWidth(getWidth());
return tModel.extractLabel(slice);
} catch (IllegalArgumentException iae) {
return getToolTipText();
}
}
private int calculateSliceWidth(double totalWidth) {
return (int) Math.floor((double) totalWidth
/ (double) tModel.getNumSlices());
}
private int convertXCoordToRow(int xCoord) {
return xCoord / calculateSliceWidth(getWidth());
}
public int convertDateToXCoord(DateTime date) {
if (!tModel.isWithinRange(date)) {
if (tModel.isBeforeStart(date)) {
return 0;
} else {
return getWidth();
}
}
Duration visibleDuration = tModel.getDuration();
Duration fromStart = new Duration(tModel.getStart(), date);
float ratio = fromStart.getMillis()
/ (float) visibleDuration.getMillis();
int xCoord = (int) Math.floor(ratio * getWidth());
return xCoord;
}
public void addListSelectionListener(ListSelectionListener listener) {
selectionModel.addListSelectionListener(listener);
}
public void removeListSelectionListener(ListSelectionListener listener) {
selectionModel.removeListSelectionListener(listener);
}
public void addIntervalListener(IntervalListener listener) {
tModel.addIntervalListener(listener);
}
public void removeIntervalListener(IntervalListener listener) {
tModel.removeIntervalListener(listener);
}
public void setModel(Timeline<T> tModel) {
if (this.tModel != null) {
clearAll();
}
if (tModel == null) {
clearAll();
return;
}
this.tModel = tModel;
tModel.addIntervalListener(new IntervalListener() {
@Override
public void propertyChange(PropertyChangeEvent evt) {
modelChanged();
}
});
modelChanged();
}
private void modelChanged() {
selectionModel.clearSelection();
zoomInAction.setEnabled(canZoomInFurther());
zoomOutAction.setEnabled(canZoomOutFurther());
int numSlices = tModel.getNumSlices();
setPreferredSize(new Dimension(40 * numSlices, 120));
setMinimumSize(new Dimension(20 * numSlices, 70));
setSize(getPreferredSize());
adjustVisibleIntervals();
}
private void adjustVisibleIntervals() {
removeAll();
if (tModel != null) {
Duration smallestVisibleDuration = calculateSmallestVisibleDuration();
for (PayloadInterval<T> interval : tModel
.getVisibleIntervals(smallestVisibleDuration)) {
IntervalView intervalView = new IntervalView(interval);
intervalView.setColors(colors);
add(intervalView);
}
}
validate();
if (isVisible()) {
repaint();
}
}
private Duration calculateSmallestVisibleDuration() {
Duration visibleDuration = tModel.getDuration();
float millisPerPixel = (float) visibleDuration.getMillis()
/ (float) getWidth();
float secondsPerPixel = millisPerPixel / 1000;
long minSeconds = (long) Math.ceil(secondsPerPixel
* SHORTEST_VISIBLE_INTERVAL);
return Duration.standardSeconds(minSeconds);
}
@Override
public void paintComponent(Graphics g) {
// draw "normal" panel stuff below everything else
super.paintComponent(g);
if (tModel == null) {
return;
}
// set up graphics config
Graphics2D graphics = (Graphics2D) g;
graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
RenderingHints.VALUE_ANTIALIAS_ON);
graphics.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING,
RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
// draw slices
if (tModel.getNumSlices() > 0) {
drawSlices(graphics);
}
}
private void drawSlices(Graphics2D graphics) {
int sliceWidth = calculateSliceWidth(getWidth());
int width = getWidth();
if (sliceWidth < 10) {
// TODO real error handling
logger.error("slices too thin, only " + sliceWidth + " pixels.");
return;
}
Paint originalPaint = graphics.getPaint();
Font originalFont = graphics.getFont();
Font font = graphics.getFont().deriveFont(Font.BOLD);
graphics.setFont(font);
int[] intervalsInSlice = new int[tModel.getNumSlices()];
int maxIntervalsPerSlice = 0;
for (int slice = 0; slice < tModel.getNumSlices(); slice++) {
Interval sliceInterval = tModel.convertSliceToInterval(slice);
int numIntervals = tModel.countIntervalsWithinRange(sliceInterval);
intervalsInSlice[slice] = numIntervals;
if (numIntervals > maxIntervalsPerSlice) {
maxIntervalsPerSlice = numIntervals;
}
}
int xCoord = 0;
DateTime sliceStart = tModel.getStart();
for (int slice = 0; slice < tModel.getNumSlices(); slice++) {
if (xCoord + sliceWidth > width) {
sliceWidth = width - xCoord;
logger.info("next slice will be only " + sliceWidth
+ " wide");
}
// draw background (or not)
boolean oddSlice = slice % 2 == 1;
if (oddSlice) {
// odd slice -> draw bg
graphics.setColor(colors.get(ColorKeys.BackgroundOdd));
graphics.fillRect(xCoord, 0, sliceWidth, getHeight());
}
// draw histogram
if (maxIntervalsPerSlice > 0 && intervalsInSlice[slice] > 0) {
double histProportion = intervalsInSlice[slice] / (double) maxIntervalsPerSlice;
int histHeight = (int) Math.round(histProportion * HISTOGRAM_HEIGHT);
if (histHeight > 0) {
graphics.setColor(colors.get(ColorKeys.HistogramFill));
graphics.fillRect(xCoord + 1, getHeight() - histHeight, sliceWidth - 1, histHeight);
}
}
// draw selection outline
if (selectionModel.isSelectedIndex(slice)) {
graphics.setColor(colors.get(ColorKeys.SelectedOutline));
graphics.setStroke(new BasicStroke(2));
graphics.drawRect(xCoord, 0, sliceWidth - 1,
getHeight() - 1);
}
// draw label
if (oddSlice) {
// set colour for label
graphics.setColor(colors.get(ColorKeys.LabelOdd));
} else {
// set colour for label
graphics.setColor(colors.get(ColorKeys.Label));
}
String sliceName = tModel.extractLabel(sliceStart);
int stringWidth = graphics.getFontMetrics().stringWidth(
sliceName);
int textXCoord = xCoord + (sliceWidth - stringWidth) / 2;
int textYCoord = graphics.getFontMetrics().getMaxAscent() + 2;
graphics.drawString(sliceName, textXCoord, textYCoord);
// update variables for next slice
xCoord += sliceWidth;
sliceStart = sliceStart.plus(tModel.getIncrement());
}
graphics.setPaint(originalPaint);
graphics.setFont(originalFont);
}
public void clearAll() {
if (tModel != null) {
tModel.clear();
}
tModel = null;
selectionModel.clearSelection();
zoomInAction.setEnabled(false);
zoomOutAction.setEnabled(false);
}
public IntervalChain<T> getSelections() {
IntervalChain<T> result = new IntervalChain<T>();
if (selectionModel.isSelectionEmpty()) {
return result;
}
List<Interval> selections = new ArrayList<Interval>();
int minSelectedIndex = selectionModel.getMinSelectionIndex();
int maxSelectedIndex = selectionModel.getMaxSelectionIndex();
DateTime lastStart = null;
int currentIndex = minSelectedIndex;
while (currentIndex <= maxSelectedIndex) {
if (selectionModel.isSelectedIndex(currentIndex)) {
if (lastStart == null) {
// start of a new interval
lastStart = tModel.convertSliceToInterval(currentIndex)
.getStart();
}
} else {
if (lastStart != null) {
// end of a new interval
DateTime end = tModel.convertSliceToInterval(currentIndex)
.getEnd();
Interval newInterval = new Interval(lastStart, end);
selections.add(newInterval);
lastStart = null;
}
}
currentIndex++;
}
// lastStart should be non-null now
if (lastStart != null) {
// end of a new interval
DateTime end = tModel.convertSliceToInterval(maxSelectedIndex)
.getEnd();
Interval newInterval = new Interval(lastStart, end);
selections.add(newInterval);
} else {
logger.warn("last start is null, shouldn't happen");
}
// TODO see if we can get this to be more efficient
for (PayloadInterval<T> interval : tModel
.getIntervalsWithinRange()) {
for (Interval selection : selections) {
if (selection.contains(interval.toInterval())) {
result.add(interval);
}
}
}
return result;
}
public boolean isSelectionEmpty() {
return selectionModel.isSelectionEmpty();
}
public Action getZoomInAction() {
return zoomInAction;
}
public Action getZoomOutAction() {
return zoomOutAction;
}
private boolean canZoomInFurther() {
if (tModel == null) {
return false;
}
boolean canZoomToSelection = !selectionModel.isSelectionEmpty()
&& tModel.getNumSlices() > 1;
return canZoomToSelection || tModel.canZoomInFurther();
}
private boolean canZoomOutFurther() {
if (tModel == null) {
return false;
}
return tModel.canZoomOutFurther();
}
public Timeline<T> getModel() {
return tModel;
}
@Override
public Dimension getMinimumSize() {
return getLayout().minimumLayoutSize(this);
}
@Override
public Dimension getPreferredSize() {
return getLayout().preferredLayoutSize(this);
}
private void zoomToSelection() {
DateTime newStart = tModel.convertSliceToInterval(
selectionModel.getMinSelectionIndex()).getStart();
DateTime newEnd = tModel.convertSliceToInterval(
selectionModel.getMaxSelectionIndex()).getEnd();
tModel.setInterval(newStart, newEnd);
}
private void zoomIn() {
long seconds = tModel.getDuration().getStandardSeconds();
int difference = (int) Math.floor(seconds / ZOOM_FACTOR);
DateTime newStart = tModel.getStart().plusSeconds(difference);
DateTime newEnd = tModel.getEnd().minusSeconds(difference);
tModel.setInterval(newStart, newEnd);
}
}