Java tutorial
/** * 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); } }