/*
* 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.model;
import java.beans.PropertyChangeListener;
import java.beans.PropertyChangeSupport;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import net.schweerelos.parrot.timeline.IntervalListener;
import org.apache.log4j.Logger;
import org.joda.time.DateTime;
import org.joda.time.DateTimeZone;
import org.joda.time.Days;
import org.joda.time.Duration;
import org.joda.time.Interval;
import org.joda.time.Minutes;
import org.joda.time.Months;
import org.joda.time.Period;
import org.joda.time.Weeks;
import org.joda.time.Years;
import org.joda.time.format.DateTimeFormat;
import org.joda.time.format.DateTimeFormatter;
public class Timeline<T> {
private static final String INTERVAL_PROPERTY_KEY = "interval";
public enum Mode { Years, Months, Weeks, Days }
private IntervalChain<T> allIntervals;
private Set<PayloadInterval<T>> currentlyVisibleIntervals = new HashSet<PayloadInterval<T>>();
private Set<PayloadInterval<T>> intervalsWithinRange = new HashSet<PayloadInterval<T>>();
private DateTime start;
private DateTime end;
private int numSlices;
private Period increment;
private Mode incrementMode;
private PropertyChangeSupport changeSupport;
private SliceLabelExtractor sliceLabelExtractor;
private Logger logger;
public Timeline(IntervalChain<T> intervals) {
logger = Logger.getLogger(Timeline.class);
allIntervals = intervals;
changeSupport = new PropertyChangeSupport(this);
if (intervals != null) {
setInterval(intervals.getFirstStart(), intervals.getLastEnd());
}
}
public void setInterval(DateTime start, DateTime end) {
DateTimeZone defaultTimeZone = DateTimeZone.getDefault();
// make sure we don't start before the start of the intervals (in years)
DateTime firstStart = allIntervals.getFirstStart();
firstStart = firstStart.withDayOfYear(firstStart.dayOfYear().getMinimumValue());
if (start != null && start.toDateTime(defaultTimeZone).isBefore(firstStart)) {
this.start = firstStart;
} else {
this.start = start;
}
// make sure we don't end after the end of the intervals (in years)
DateTime lastEnd = allIntervals.getLastEnd();
lastEnd = lastEnd.withDayOfYear(lastEnd.dayOfYear().getMaximumValue());
if (end != null && end.toDateTime(defaultTimeZone).isAfter(lastEnd)) {
this.end = lastEnd;
} else {
this.end = end;
}
recalculate();
}
private void recalculate() {
if (start == null || end == null) {
logger.warn("recalculating aborted, start and/or end is null");
numSlices = 0;
return;
}
Interval interval = new Interval(start, end);
if (Years.yearsIn(interval).isGreaterThan(Years.ZERO)) {
// make it start at the start of the current increment mode
start = start.withDayOfYear(start.dayOfYear().getMinimumValue());
end = end.withDayOfYear(end.dayOfYear().getMaximumValue());
interval = new Interval(start, end);
// figure out number of slices
numSlices = Years.yearsIn(interval).getYears();
if (start.plusYears(numSlices).isBefore(end)) {
numSlices += 1;
}
// update label extractor
sliceLabelExtractor = new SliceLabelExtractor() {
@Override
public String extractLabel(DateTime from) {
return from.year().getAsShortText();
}
};
// update increment
increment = Years.ONE.toPeriod();
incrementMode = Mode.Years;
} else if (Months.monthsIn(interval).isGreaterThan(Months.ZERO)) {
// make it start at the start of the current increment mode
start = start.withDayOfMonth(start.dayOfMonth().getMinimumValue());
end = end.withDayOfMonth(end.dayOfMonth().getMaximumValue());
interval = new Interval(start, end);
numSlices = Months.monthsIn(interval).getMonths();
if (start.plusMonths(numSlices).isBefore(end)) {
numSlices += 1;
}
sliceLabelExtractor = new SliceLabelExtractor() {
@Override
public String extractLabel(DateTime from) {
return from.monthOfYear().getAsShortText();
}
};
increment = Months.ONE.toPeriod();
incrementMode = Mode.Months;
} else if (Weeks.weeksIn(interval).isGreaterThan(Weeks.ZERO)) {
start = start.withDayOfWeek(start.dayOfWeek().getMinimumValue());
end = end.withDayOfWeek(end.dayOfWeek().getMaximumValue());
interval = new Interval(start, end);
numSlices = Weeks.weeksIn(interval).getWeeks();
if (start.plusWeeks(numSlices).isBefore(end)) {
numSlices += 1;
}
sliceLabelExtractor = new SliceLabelExtractor() {
@Override
public String extractLabel(DateTime from) {
return "W" + from.weekOfWeekyear().getAsShortText();
}
};
increment = Weeks.ONE.toPeriod();
incrementMode = Mode.Weeks;
} else {
numSlices = Days.daysIn(interval).getDays();
if (start.plusDays(numSlices).isBefore(end)) {
numSlices += 1;
}
if (numSlices == 0) {
// force at least one day to be drawn
numSlices = 1;
}
sliceLabelExtractor = new SliceLabelExtractor() {
@Override
public String extractLabel(DateTime from) {
return from.dayOfMonth().getAsShortText();
}
};
increment = Days.ONE.toPeriod();
incrementMode = Mode.Days;
}
// reset time of day too
start = start.withMillisOfDay(start.millisOfDay().getMinimumValue());
end = end.withMillisOfDay(end.millisOfDay().getMaximumValue());
// recalculate which intervals are within range
intervalsWithinRange.clear();
intervalsWithinRange.addAll(calculateIntervalsWithinRange(start, end));
// notify listeners
changeSupport.firePropertyChange(INTERVAL_PROPERTY_KEY, interval, new Interval(start, end));
}
private Set<PayloadInterval<T>> calculateIntervalsWithinRange(
DateTime rangeStart, DateTime rangeEnd) {
Set<PayloadInterval<T>> result = new HashSet<PayloadInterval<T>>();
Interval range = new Interval(rangeStart, rangeEnd);
for (PayloadInterval<T> interval : allIntervals) {
if (range.contains(interval.getStart()) && range.contains(interval.getEnd())) {
result.add(interval);
}
}
return result;
}
public Set<PayloadInterval<T>> getVisibleIntervals(Duration minLength) {
Set<PayloadInterval<T>> result = new HashSet<PayloadInterval<T>>();
List<PayloadInterval<T>> intervals = allIntervals.getIntervals();
for (PayloadInterval<T> interval : intervals) {
DateTime intervalStart = interval.getStart();
DateTime intervalEnd = interval.getEnd();
// if interval is completely outside of time shown in timeline
// -> skip this interval
if (intervalEnd.isBefore(start) || intervalStart.isAfter(end)) {
continue;
}
// if interval is too short
// -> skip this interval
Duration length = interval.toInterval().toDuration();
if (length.isShorterThan(minLength)) {
continue;
}
if (isWithinRange(intervalStart)) {
if (isWithinRange(intervalEnd)) {
// interval is completely inside time shown in timeline
result.add(interval);
} else {
// interval starts during timeline but ends later
result.add(interval);
}
} else if (isWithinRange(intervalEnd)) {
// interval starts before timeline but ends within
result.add(interval);
} else {
// interval start before timeline and ends later
result.add(interval);
}
}
return result;
}
public boolean isWithinRange(DateTime date) {
boolean notBeforeStart = date.isAfter(start) || date.isEqual(start);
boolean notAfterEnd = date.isBefore(end) || date.isEqual(end);
return notBeforeStart && notAfterEnd;
}
public DateTime getStart() {
return start;
}
public DateTime getEnd() {
return end;
}
public Duration getDuration() {
return new Duration(start, end);
}
public boolean isBeforeStart(DateTime date) {
return date.isBefore(start);
}
public void addIntervalListener(IntervalListener listener) {
changeSupport.addPropertyChangeListener(INTERVAL_PROPERTY_KEY, listener);
}
public void removeIntervalListener(IntervalListener listener) {
changeSupport.removePropertyChangeListener(INTERVAL_PROPERTY_KEY, listener);
}
public Set<PayloadInterval<T>> getIntervalsWithinRange(){
return intervalsWithinRange;
}
public void clear() {
PropertyChangeListener[] listeners = changeSupport.getPropertyChangeListeners();
for (int i = 0; i < listeners.length; i++) {
changeSupport.removePropertyChangeListener(listeners[i]);
}
allIntervals = null;
currentlyVisibleIntervals.clear();
intervalsWithinRange.clear();
start = null;
end = null;
numSlices = 0;
increment = null;
sliceLabelExtractor = null;
}
public int getNumSlices() {
return numSlices;
}
public Period getIncrement() {
return increment;
}
public Mode getIncrementMode() {
return incrementMode;
}
public boolean canZoomInFurther() {
if (allIntervals == null || incrementMode == null) {
return false;
}
return numSlices > 1 || incrementMode != Mode.Days;
}
public boolean canZoomOutFurther() {
if (allIntervals == null || start == null || end == null) {
return false;
}
return start.isAfter(allIntervals.getFirstStart()) || end.isBefore(allIntervals.getLastEnd());
}
public Interval convertSliceToInterval(int row) {
if (row > -1) {
DateTime periodStart = start;
for (int i = 0; i < row; i++) {
Duration addDuration = increment.toDurationFrom(periodStart);
periodStart = periodStart.plus(addDuration);
}
Duration addDuration = increment.toDurationFrom(periodStart);
DateTime periodEnd = periodStart.plus(addDuration);
if (periodEnd.isAfter(end)) {
periodEnd = end;
}
periodEnd = periodEnd.minus(Minutes.ONE);
return new Interval(periodStart, periodEnd);
} else {
return null;
}
}
private interface SliceLabelExtractor {
String extractLabel(DateTime from);
}
public String extractLabel(DateTime sliceStart) {
return sliceLabelExtractor.extractLabel(sliceStart);
}
public String extractLabel(int slice) {
Interval interval;
try {
interval = convertSliceToInterval(slice);
} catch (IllegalArgumentException iae) {
iae.printStackTrace();
return "";
}
DateTimeFormatter format = DateTimeFormat.shortDate();
if (incrementMode == Mode.Days) {
return interval.getStart().toString(format);
} else {
String incrementString = "";
switch (incrementMode) {
case Years:
incrementString = "Year " + sliceLabelExtractor.extractLabel(interval.getStart()) + " (";
break;
case Months:
incrementString = "Month " + sliceLabelExtractor.extractLabel(interval.getStart()) + " (";
break;
case Weeks:
incrementString = "Week " + sliceLabelExtractor.extractLabel(interval.getStart()) + " (";
break;
}
return incrementString + interval.getStart().toString(format) + " to " + interval.getEnd().toString(format) + ")";
}
}
public int countIntervalsWithinRange(Interval sliceInterval) {
Set<PayloadInterval<T>> intervals = calculateIntervalsWithinRange(sliceInterval.getStart(), sliceInterval.getEnd());
return intervals.size();
}
}