/**
* Copyright 2010 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS-IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.livingstories.client.ui;
import com.google.gwt.event.dom.client.ClickEvent;
import com.google.gwt.event.dom.client.ClickHandler;
import com.google.gwt.user.client.ui.AbsolutePanel;
import com.google.gwt.user.client.ui.Composite;
import com.google.gwt.user.client.ui.HasHorizontalAlignment;
import com.google.gwt.user.client.ui.Image;
import com.google.gwt.user.client.ui.Label;
import com.google.gwt.user.client.ui.SimplePanel;
import com.google.livingstories.client.util.DateUtil;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
/**
* A widget used to render an interactive timeline, with boxes, pop-up captions, and
* optional event handlers for clicking on a particular box or its caption.
*/
public class TimelineWidget<T> extends Composite {
private Date displayStarts;
private Date displayEnds;
private Map<Date, TimelineData<T>> pointEvents;
private Map<Interval, TimelineData<T>> rangeEvents;
private Set<Interval> filteredRangeKeys;
private AbsolutePanel absolutePanel;
private long daysSpanned;
private float pixelsPerDay;
private int eventInsertionHeight;
private int widthInPixels;
private int heightInPixels;
private int maxXPos;
private OnClickBehavior<T> onClickBehavior;
private Image leftArrow;
private Image rightArrow;
private int globalXOffset = 0;
private int offscreenXLeft = 10000; // proper values will be < HALF_EVENT_WIDTH
private int offscreenXRight = -10000; // proper values will be > maxXPos
public static int DEFAULT_WIDTH = 450;
public static int DEFAULT_HEIGHT = 150;
// Nominal width of a date label; actually narrower than this; the text will be
// centered within the box.
private static final int DATE_LABEL_WIDTH = 400;
private static final int DATE_LABEL_OFFSET = 26;
private static final int HASHMARK_HEIGHT = 12;
private static final int ARROW_HEAD_WIDTH = 10;
private static final int ARROW_HEAD_HEIGHT = 11;
private static final int ARROW_BODY_HEIGHT = 5;
private static final int EVENT_WIDTH = 90;
private static final int HALF_EVENT_WIDTH = EVENT_WIDTH / 2;
// Actual event descriptions are placed slightly off-center compared to their
// hash marks because the event box text is left-justified, not center-justified. Lining up
// the hashmark to the center of the box doesn't work well.
private static final int EVENT_OFFSET = 15;
private static final int PADDED_EVENT_WIDTH = EVENT_WIDTH + 10;
/**
* Constructs a new TimelineWidget
* @param widthInPixels the width of the timeline
* @param heightInPixels the height of the timeline
* @param onClickBehavior an object encapsulating what should happen when the user clicks on the
* timeline. null is allowable, although in such cases the generic type of the TimelineWidget
* will not be deducible.
*/
public TimelineWidget(Integer widthInPixels, Integer heightInPixels,
OnClickBehavior<T> onClickBehavior) {
this.widthInPixels = (widthInPixels == null ? DEFAULT_WIDTH : widthInPixels);
this.heightInPixels = (heightInPixels == null ? DEFAULT_HEIGHT : heightInPixels);
this.onClickBehavior = onClickBehavior;
maxXPos = this.widthInPixels - HALF_EVENT_WIDTH - EVENT_OFFSET;
// Make eventInsertionHeight one-third of the way down into the widget, rounding down.
eventInsertionHeight = Math.round(((float) this.heightInPixels) / 3);
absolutePanel = new AbsolutePanel();
absolutePanel.setSize(this.widthInPixels + "px", this.heightInPixels + "px");
absolutePanel.setStylePrimaryName("timelinePanel");
leftArrow = new Image("/images/inverse-arrowhead-left.gif");
leftArrow.setStylePrimaryName("disabledArrowHead");
rightArrow = new Image("/images/inverse-arrowhead-right.gif");
rightArrow.setStylePrimaryName("disabledArrowHead");
addClickHandlers();
initWidget(absolutePanel);
}
public void load(
Interval displayRange,
Map<Date, TimelineData<T>> pointEvents,
Map<Interval, TimelineData<T>> rangeEvents) {
this.pointEvents = pointEvents;
this.rangeEvents = rangeEvents;
this.filteredRangeKeys = filterRangeEvents(rangeEvents.keySet(), pointEvents.keySet());
displayStarts = displayRange.getStartDateTime();
displayEnds = displayRange.getEndDateTime();
daysSpanned = DateUtil.numberOfDaysApart(displayStarts, displayEnds);
pixelsPerDay = ((float) maxXPos - HALF_EVENT_WIDTH) / Math.max(daysSpanned, 1);
globalXOffset = 0;
loadImpl();
}
private void addClickHandlers() {
leftArrow.addClickHandler(new ClickHandler() {
@Override
public void onClick(ClickEvent event) {
if (offscreenXLeft < HALF_EVENT_WIDTH) {
// adjust globalXOffset so that one more leftward timeline marker is just visible.
// The goal is to increase globalXOffset
globalXOffset += HALF_EVENT_WIDTH - offscreenXLeft;
reload();
}
}
});
rightArrow.addClickHandler(new ClickHandler() {
@Override
public void onClick(ClickEvent event) {
if (offscreenXRight > maxXPos) {
// adjust globalXOffset so that one more rightward timeline marker is just visible.
// The goal is to decrease globalXOffset
globalXOffset -= offscreenXRight - maxXPos;
reload();
}
}
});
}
/**
* Call this to clear the absolutepanel and reload all widgets.
*/
private void reload() {
absolutePanel.clear();
loadImpl();
}
/**
* Determines which of the intervals in ranges can be shown on the timeline, in that
* they don't overlap any point events or an earlier range. Note that if there's a set of
* overlapping event ranges, some will usually be shown; the algorithm will drop some of the
* conflicting ones, though, usually favoring earlier events over later. (The exact details
* depend on the relative endpoints of the range and whether there were any interfering point
* events as well.) Returns the filtered ranges, as a set.
* @param ranges the ranges to check
* @param points the point events to check
* @return ranges, filtered
*/
private Set<Interval> filterRangeEvents(Set<Interval> ranges, Set<Date> points) {
// Trailing nulls below serve as sentinel values
List<Interval> rangeList = new ArrayList<Interval>(ranges);
Collections.sort(rangeList);
rangeList.add(null);
List<Date> pointList = new ArrayList<Date>(points);
Collections.sort(pointList);
pointList.add(null);
// Iterate through the points and ranges in tandem.
Iterator<Interval> rangeIt = rangeList.iterator();
Iterator<Date> pointIt = pointList.iterator();
Set<Interval> ret = new HashSet<Interval>();
Interval range = rangeIt.next();
Date point = pointIt.next();
Interval previousAddedRange = null;
while (range != null) { // a good loop condition, since rangeList has a sentinel null
if (point != null && point.before(range.getStartDateTime())) {
// advance point, but do nothing else
point = pointIt.next();
} else {
// advance the range, putting it into ret if appropriate.
if (point == null || point.after(range.getEndDateTime())
&& (previousAddedRange == null
|| previousAddedRange.getEndDateTime().before(range.getStartDateTime()))) {
// Point can't possibly overlap range, nor does range overlap with the previously
// added range. Add it to the return set.
ret.add(range);
previousAddedRange = range;
}
range = rangeIt.next();
}
}
return ret;
}
private void loadImpl() {
// put the background arrow on the widget
int arrowTop = eventInsertionHeight - HASHMARK_HEIGHT / 2;
absolutePanel.add(leftArrow, 0, arrowTop);
absolutePanel.add(rightArrow, widthInPixels - ARROW_HEAD_WIDTH, arrowTop);
SimplePanel arrowBody = new SimplePanel();
arrowBody.setStylePrimaryName("arrowBody");
arrowBody.setSize((widthInPixels - 2 * ARROW_HEAD_WIDTH) + "px", ARROW_BODY_HEIGHT + "px");
absolutePanel.add(arrowBody, ARROW_HEAD_WIDTH,
arrowTop + (ARROW_HEAD_HEIGHT - ARROW_BODY_HEIGHT) / 2);
// Now build up an alternate map of Intervals to event strings. Using a TreeMap gives us
// increasing dates by key, which is convenient.
Map<Interval, TimelineData<T>> events = new TreeMap<Interval, TimelineData<T>>();
for (Interval rangeKey : filteredRangeKeys) {
events.put(rangeKey, rangeEvents.get(rangeKey));
}
for (Date pointKey : pointEvents.keySet()) {
events.put(new Interval(pointKey, pointKey), pointEvents.get(pointKey));
}
// some sufficiently positive value here to start. Don't be tempted to use
// Integer.MAX_VALUE here; the subtraction below may end up overflowing.
int previousXPosMid = 500000;
offscreenXLeft = 10000;
offscreenXRight = -10000;
// we process the events from most recent to least, rather than the other way around, so that,
// when globalXOffset is 0, we're biased towards showing the most-recent events rather than
// the least-recent.
List<Interval> intervalsReversed = new ArrayList<Interval>(events.keySet());
Collections.reverse(intervalsReversed);
for (Interval interval : intervalsReversed) {
Date startDate = interval.getStartDateTime();
Date endDate = interval.getEndDateTime();
int xPosStart = globalXOffset + mapDateToPixelPosition(startDate);
int xPosEnd = startDate.equals(endDate) ? xPosStart
: (globalXOffset + mapDateToPixelPosition(endDate));
int xPosMid = (xPosStart + xPosEnd) / 2;
int widthShortfall = xPosMid + PADDED_EVENT_WIDTH - previousXPosMid;
if (widthShortfall > 0) {
xPosStart -= widthShortfall;
xPosMid -= widthShortfall;
xPosEnd -= widthShortfall;
}
if (xPosMid > maxXPos) {
offscreenXRight = xPosMid;
continue;
}
if (xPosMid < HALF_EVENT_WIDTH) {
offscreenXLeft = xPosMid;
break;
}
// We enclose the date label in an extra-wide widget to ensure that it's centered
// over the hash mark.
String dateString = DateUtil.formatDate(startDate);
if (!startDate.equals(endDate)) {
dateString += " - " + DateUtil.formatDate(endDate);
}
Label dateLabel = new Label(dateString);
dateLabel.setStylePrimaryName("timelineDate");
dateLabel.setWidth(DATE_LABEL_WIDTH + "px");
dateLabel.setHorizontalAlignment(HasHorizontalAlignment.ALIGN_CENTER);
SimplePanel hashMark = new SimplePanel();
hashMark.setStylePrimaryName("hashMark");
hashMark.setWidth((xPosEnd - xPosStart + 1) + "px");
TimelineData<T> timelineData = events.get(interval);
Label eventLabel = new Label(timelineData.getLabel(), true);
eventLabel.setStylePrimaryName("timelineEvent");
eventLabel.setWidth(EVENT_WIDTH + "px");
int eventBoxY = eventInsertionHeight + HASHMARK_HEIGHT / 2;
eventLabel.getElement().getStyle().setPropertyPx(
"maxHeight", heightInPixels - eventBoxY);
final T data = timelineData.getData();
if (onClickBehavior != null && data != null) {
eventLabel.addStyleName("clickableTimelineEvent");
eventLabel.addStyleName("secondaryLink");
eventLabel.addClickHandler(new ClickHandler() {
@Override
public void onClick(ClickEvent event) {
onClickBehavior.onClick(event, data);
}
});
}
absolutePanel.add(dateLabel, xPosMid- DATE_LABEL_WIDTH / 2,
eventInsertionHeight - DATE_LABEL_OFFSET);
absolutePanel.add(hashMark, xPosStart, eventInsertionHeight - HASHMARK_HEIGHT / 2);
absolutePanel.add(eventLabel, xPosMid - HALF_EVENT_WIDTH + EVENT_OFFSET, eventBoxY);
previousXPosMid = xPosMid;
}
// offScreenXLeft will be < HALF_EVENT_WIDTH iff it was actually set. Similarly for
// offScreenXRight.
leftArrow.setStylePrimaryName(offscreenXLeft < HALF_EVENT_WIDTH
? "enabledArrowHead" : "disabledArrowHead");
rightArrow.setStylePrimaryName(offscreenXRight > maxXPos
? "enabledArrowHead" : "disabledArrowHead");
}
private int mapDateToPixelPosition(Date date) {
long daysAfterStart = DateUtil.numberOfDaysApart(displayStarts, date);
return Math.round(daysAfterStart * pixelsPerDay + HALF_EVENT_WIDTH);
}
public static class Interval implements Comparable<Interval> {
private Date startDateTime;
private Date endDateTime;
public Interval(Date startDateTime, Date endDateTime) {
this.startDateTime = startDateTime;
this.endDateTime = endDateTime;
}
/**
* A utility constructor; leverages DateUtil.makeDate {@link DateUtil}
*/
public Interval(int y1, int m1, int d1, int y2, int m2, int d2) {
this(DateUtil.makeDate(y1, m1, d1), DateUtil.makeDate(y2, m2, d2));
}
public Date getStartDateTime() {
return startDateTime;
}
public Date getEndDateTime() {
return endDateTime;
}
@Override
public int compareTo(Interval rhs) {
int t = startDateTime.compareTo(rhs.startDateTime);
return (t == 0) ? endDateTime.compareTo(rhs.endDateTime) : t;
}
}
/**
* Classes that implement this interface encapsulate a behavior triggered when the user clicks
* on a timeline label. (Timeline labels have generic data associated with them of type T.)
*/
public interface OnClickBehavior<T> {
void onClick(ClickEvent event, T arg);
}
}