/*
* TripController.java - Copyright(c) 2013 Joe Pasqua
* Provided under the MIT License. See the LICENSE file for details.
* Created: Nov 27, 2013
*/
package org.noroomattheinn.visibletesla;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.NavigableMap;
import javafx.application.Platform;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.collections.FXCollections;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.scene.control.Button;
import javafx.scene.control.CheckBox;
import javafx.scene.control.Dialogs;
import javafx.scene.control.ListView;
import javafx.scene.control.SelectionMode;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.control.cell.PropertyValueFactory;
import javafx.stage.FileChooser;
import javafx.util.Callback;
import jfxtras.labs.scene.control.CalendarPicker;
import org.apache.commons.io.FileUtils;
import org.noroomattheinn.tesla.ChargeState;
import org.noroomattheinn.tesla.StreamState;
import org.noroomattheinn.timeseries.Row;
import org.noroomattheinn.utils.GeoUtils;
import org.noroomattheinn.utils.SimpleTemplate;
import org.noroomattheinn.utils.Utils;
import org.noroomattheinn.visibletesla.data.Trip;
import org.noroomattheinn.visibletesla.data.VTData;
import org.noroomattheinn.visibletesla.data.WayPoint;
import static org.noroomattheinn.tesla.Tesla.logger;
/**
* TripController: Manage the recording, selection and display of Trips
*
* @author Joe Pasqua <joe at NoRoomAtTheInn dot org>
*/
public class TripController extends BaseController {
/*------------------------------------------------------------------------------
*
* Constants and Enums
*
*----------------------------------------------------------------------------*/
private static final String IncludeGraphKey = "TR_INCLUDE_GRAPH";
private static final String SnapToRoadKey = "TR_SNAP";
private static final String PathTemplateFileName = "PathTemplate.html";
private static final long MaxTimeBetweenWayPoints = 15 * 60 * 1000;
private static final String RangeRowName = "Range";
private static final String OdoRowName = "Odometer";
/*------------------------------------------------------------------------------
*
* Internal State
*
*----------------------------------------------------------------------------*/
private boolean useMiles = true;
private Map<String,List<Trip>> dateToTrips = new HashMap<>();
private Map<String,Trip> selectedTrips = new HashMap<>();
// Each of the following items defines a row of the property table
private final GenericProperty rangeRow = new GenericProperty(RangeRowName, "0.0", "0.0");
private final GenericProperty socRow = new GenericProperty("SOC (%)", "0.0", "0.0");
private final GenericProperty odoRow = new GenericProperty(OdoRowName, "0.0", "0.0");
private final GenericProperty powerRow = new GenericProperty("Energy (kWh)", "0.0", "0.0");
private final ObservableList<GenericProperty> data = FXCollections.observableArrayList(
rangeRow, socRow, odoRow, powerRow);
/*------------------------------------------------------------------------------
*
* UI Elements
*
*----------------------------------------------------------------------------*/
@FXML private CalendarPicker calendarPicker;
@FXML private Button mapItButton;
@FXML private Button exportItButton;
@FXML private ListView<String> availableTripsView;
@FXML private CheckBox includeGraph;
@FXML private CheckBox snapToRoad;
// The Property TableView and its Columns
@FXML private TableView<GenericProperty> propertyTable;
@FXML private TableColumn<GenericProperty,String> propNameCol;
@FXML private TableColumn<GenericProperty,String> propStartCol;
@FXML private TableColumn<GenericProperty,String> propEndCol;
/*------------------------------------------------------------------------------
*
* UI Action Handlers
*
*----------------------------------------------------------------------------*/
private List<Trip> getSelectedTrips() {
ArrayList<Trip> selection = new ArrayList<>();
for (String item : availableTripsView.getSelectionModel().getSelectedItems()) {
Trip t = selectedTrips.get(item);
if (t != null) selection.add(t);
}
Collections.sort(selection, new Comparator<Trip>() {
@Override
public int compare(Trip o1, Trip o2) {
return Long.signum(o1.firstWayPoint().getTime() - o2.firstWayPoint().getTime());
}
});
return selection;
}
@FXML void exportItHandler(ActionEvent event) {
String initialDir = prefs.storage().get(
App.LastExportDirKey, System.getProperty("user.home"));
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Export Trip as KMZ");
fileChooser.setInitialDirectory(new File(initialDir));
File file = fileChooser.showSaveDialog(app.stage);
if (file != null) {
String enclosingDirectory = file.getParent();
if (enclosingDirectory != null)
prefs.storage().put(App.LastExportDirKey, enclosingDirectory);
if (vtData.exportTripsAsKML(getSelectedTrips(), file)) {
Dialogs.showInformationDialog(
app.stage, "Your data has been exported",
"Data Export Process" , "Export Complete");
} else {
Dialogs.showWarningDialog(
app.stage, "There was a problem exporting your trip data to KMZ",
"Data Export Process" , "Export Failed");
}
}
}
@FXML void mapItHandler(ActionEvent event) {
List<Trip> trips = getSelectedTrips();
String map = getMapFromTemplate(trips);
try {
File tempFile = File.createTempFile("VTTrip", ".html");
FileUtils.write(tempFile, map);
app.showDocument(tempFile.toURI().toString());
} catch (IOException ex) {
logger.warning("Unable to create temp file");
// TO DO: Pop up a dialog!
}
}
@FXML void todayHandler(ActionEvent event) {
calendarPicker.calendars().clear();
calendarPicker.calendars().add(new GregorianCalendar());
}
@FXML void endTripHandler(ActionEvent event) {
endCurrentTrip();
}
@FXML void clearSelection(ActionEvent event) {
mapItButton.setDisable(true);
exportItButton.setDisable(true);
calendarPicker.calendars().clear();
availableTripsView.getItems().clear();
selectedTrips.clear();
}
/*------------------------------------------------------------------------------
*
* Methods overridden from BaseController
*
*----------------------------------------------------------------------------*/
@Override protected void fxInitialize() {
mapItButton.setDisable(true);
exportItButton.setDisable(true);
availableTripsView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
availableTripsView.getSelectionModel().getSelectedItems().addListener(new ListChangeListener<String>() {
@Override public void onChanged(ListChangeListener.Change<? extends String> change) {
reflectSelection();
}
});
calendarPicker.calendars().addListener(new ListChangeListener<Calendar>() {
@Override public void onChanged(ListChangeListener.Change<? extends Calendar> change) {
updateTripSelections();
}
});
calendarPicker.setCalendarRangeCallback(new Callback<CalendarPicker.CalendarRange,java.lang.Void>() {
@Override public Void call(CalendarPicker.CalendarRange p) {
highlightDaysWithTrips(p.getStartCalendar());
return null;
} });
includeGraph.selectedProperty().addListener(new ChangeListener<Boolean>() {
@Override public void changed(ObservableValue<? extends Boolean> ov, Boolean t, Boolean t1) {
prefs.storage().putBoolean(IncludeGraphKey, t1);
}
});
snapToRoad.selectedProperty().addListener(new ChangeListener<Boolean>() {
@Override public void changed(ObservableValue<? extends Boolean> ov, Boolean t, Boolean t1) {
prefs.storage().putBoolean(SnapToRoadKey, t1);
}
});
// Prepare the property table
propNameCol.setCellValueFactory( new PropertyValueFactory<GenericProperty,String>("name") );
propStartCol.setCellValueFactory( new PropertyValueFactory<GenericProperty,String>("value") );
propEndCol.setCellValueFactory( new PropertyValueFactory<GenericProperty,String>("units") );
propertyTable.setItems(data);
}
@Override protected void activateTab() {
useMiles = vtVehicle.unitType() == Utils.UnitType.Imperial;
String units = useMiles ? " (mi)" : " (km)";
rangeRow.setName(RangeRowName + units);
odoRow.setName(OdoRowName + units);
}
@Override protected void initializeState() {
includeGraph.setSelected(
prefs.storage().getBoolean(IncludeGraphKey, false));
snapToRoad.setSelected(
prefs.storage().getBoolean(SnapToRoadKey, false));
readTrips();
}
@Override protected void refresh() { }
/*------------------------------------------------------------------------------
*
* PRIVATE - Utility Methods and Classes
*
*----------------------------------------------------------------------------*/
private void reflectTripInfo() {
List<Trip> trips = getSelectedTrips();
if (trips == null || trips.isEmpty()) return;
WayPoint start = trips.get(0).firstWayPoint();
WayPoint end = trips.get(trips.size()-1).lastWayPoint();
double cvt = useMiles ? 1.0 : Utils.KilometersPerMile;
updateStartEndProps(
VTData.EstRangeKey, start.getTime(), end.getTime(),
rangeRow, cvt);
updateStartEndProps(
VTData.SOCKey, start.getTime(), end.getTime(),
socRow, 1.0);
updateStartEndProps(odoRow, start.getOdo(), end.getOdo(), cvt);
double power = 0.0;
for (Trip t:trips) {
power += t.estimateEnergy();
}
updateStartEndProps(powerRow, 0.0, power, 1.0);
}
private void updateStartEndProps(
String statType, long startTime, long endTime,
GenericProperty prop, double conversionFactor) {
NavigableMap<Long,Row> rows = vtData.getRangeOfLoadedRows(startTime, endTime);
double startValue = rows.firstEntry().getValue().get(VTData.schema, statType);
double endValue = rows.lastEntry().getValue().get(VTData.schema, statType);
prop.setValue(String.format("%.1f", startValue * conversionFactor));
prop.setUnits(String.format("%.1f", endValue * conversionFactor));
}
private void updateStartEndProps(
GenericProperty prop,
double startVal, double endVal, double conversionFactor) {
prop.setValue(String.format("%.1f", startVal*conversionFactor));
prop.setUnits(String.format("%.1f", endVal*conversionFactor));
}
private void reflectSelection() {
reflectTripInfo();
mapItButton.setDisable(selectedTrips.isEmpty());
exportItButton.setDisable(selectedTrips.isEmpty());
}
/**
* Must be run on the FX Application Thread
*/
private void updateTripSelections() {
mapItButton.setDisable(true);
exportItButton.setDisable(true);
selectedTrips.clear();
availableTripsView.getItems().clear();
if (calendarPicker.calendars().size() == 0) {
return;
}
double cvt = useMiles ? 1.0 : Utils.KilometersPerMile;
for (Calendar c : calendarPicker.calendars()) {
String dateKey = keyFromDate(c.getTime());
List<Trip> trips = dateToTrips.get(dateKey);
if (trips != null) {
for (Trip t : trips) {
String id = String.format("%s @ %s, %.1f %s",
dateKey, hourAndMinutes(t.firstWayPoint().getTime()),
t.distance()*cvt, useMiles ? "mi" : "km");
selectedTrips.put(id, t);
availableTripsView.getItems().add(id);
}
}
}
}
private String keyFromDate(Date d) {
return String.format("%1$tY-%1$tm-%1$td", d);
}
private String hourAndMinutes(long time) {
return String.format("%1$tH:%1$tM", new Date(time));
}
private String getMapFromTemplate(List<Trip> trips) {
SimpleTemplate template = new SimpleTemplate(getClass().getResourceAsStream(PathTemplateFileName));
StringBuilder sb = new StringBuilder();
sb.append("[\n");
boolean first = true;
for (Trip t : trips) {
if (includeGraph.isSelected()) { t.addElevationData(); }
if (!first) sb.append(",\n");
sb.append(t.asJSON(useMiles));
first = false;
}
sb.append("]\n");
Date date = new Date(trips.get(0).firstWayPoint().getTime());
return template.fillIn(
"TRIPS", sb.toString(),
"TITLE", "Tesla Path on " + date,
"EL_UNITS", useMiles ? "feet" : "meters",
"SP_UNITS", useMiles ? "mph" : "km/h",
"INCLUDE_GRAPH", includeGraph.isSelected() ? "true" : "false",
"SNAP", snapToRoad.isSelected() ? "true" : "false",
"GMAP_API_KEY", prefs.useCustomGoogleAPIKey.get() ?
prefs.googleAPIKey.get() :
Prefs.GoogleMapsAPIKey);
}
private boolean sameMonth(Calendar month, Calendar day) {
return (month.get(Calendar.YEAR) == day.get(Calendar.YEAR) &&
month.get(Calendar.MONTH) == day.get(Calendar.MONTH));
}
private void highlightDaysWithTrips(Calendar month) {
List<Calendar> daysToHighlight = new ArrayList<>();
for (List<Trip> trips : dateToTrips.values()) {
Calendar day = Calendar.getInstance();
day.setTimeInMillis(trips.get(0).firstWayPoint().getTime());
if (sameMonth(month, day)) daysToHighlight.add(day);
}
calendarPicker.highlightedCalendars().clear();
calendarPicker.highlightedCalendars().addAll(daysToHighlight);
}
/*------------------------------------------------------------------------------
*
* PRIVATE - Methods and Classes for handling Trips and WayPoints
*
*----------------------------------------------------------------------------*/
private void readTrips() {
Map<Long,Row> rows = vtData.getAllLoadedRows();
for (Row r : rows.values()) {
double lat = r.get(VTData.schema, VTData.LatitudeKey);
double lng = r.get(VTData.schema, VTData.LongitudeKey);
double odo = r.get(VTData.schema, VTData.OdometerKey);
if (lat == 0.0 && lng == 0.0 || odo == 0.0) continue;
WayPoint wp = new WayPoint(
r.timestamp,
odo,
r.get(VTData.schema, VTData.SpeedKey),
r.get(VTData.schema, VTData.HeadingKey),
lat, lng, Double.NaN,
r.get(VTData.schema, VTData.PowerKey),
r.get(VTData.schema, VTData.SOCKey));
handleNewWayPoint(wp);
}
endCurrentTrip();
// Start listening for new WayPoints
vtData.lastStoredStreamState.addTracker(new Runnable() {
@Override public void run() {
StreamState ss = vtData.lastStoredStreamState.get();
ChargeState cs = vtData.lastStoredChargeState.get();
handleNewWayPoint(
new WayPoint(
ss.timestamp, ss.odometer, ss.speed,
ss.heading, ss.estLat, ss.estLng, Double.NaN,
ss.power, cs.batteryPercent));
}
});
highlightDaysWithTrips(Calendar.getInstance());
}
private void endCurrentTrip() {
if (tripInProgress == null) return;
if (tripInProgress.distance() > 0.1) {
updateTripData(tripInProgress);
}
tripInProgress = null;
}
private Trip tripInProgress = null;
private void handleNewWayPoint(WayPoint wp) {
if (tripInProgress == null) {
// No Trip in progress, start one
startNewTrip(wp);
return;
}
WayPoint last = tripInProgress.lastWayPoint();
if ((wp.getTime() - last.getTime() > MaxTimeBetweenWayPoints)) {
// Finish the old trip and start a new one
endCurrentTrip();
startNewTrip(wp);
return;
}
if (thereWasMotion(wp, last)) {
// Add to the current Trip
tripInProgress.addWayPoint(wp);
}
}
private void startNewTrip(WayPoint wp) {
tripInProgress = new Trip();
tripInProgress.addWayPoint(wp);
}
private void updateTripData(Trip t) {
WayPoint wp = t.firstWayPoint();
Date d = new Date(wp.getTime());
String dateKey = keyFromDate(d);
List<Trip> tripsForDay = dateToTrips.get(dateKey);
if (tripsForDay == null) {
tripsForDay = new ArrayList<>();
dateToTrips.put(dateKey, tripsForDay);
}
tripsForDay.add(t);
if (dateIsSelected(d)) {
Platform.runLater(new Runnable() {
@Override public void run() { updateTripSelections(); } });
}
}
private boolean dateIsSelected(Date d) {
Calendar tripDay = Calendar.getInstance();
tripDay.setTime(d);
int yr = tripDay.get(Calendar.YEAR);
int doy = tripDay.get(Calendar.DAY_OF_YEAR);
for (Calendar c : calendarPicker.calendars()) {
if (c.get(Calendar.YEAR) == yr && c.get(Calendar.DAY_OF_YEAR) == doy) {
return true;
}
}
return false;
}
/*------------------------------------------------------------------------------
*
* PRIVATE - Methods to check proximity between points
*
*----------------------------------------------------------------------------*/
private boolean thereWasMotion(WayPoint wp1, WayPoint wp2) {
double turn = 180.0 - Math.abs((Math.abs(wp1.getHeading() - wp2.getHeading())%360.0) - 180.0);
double meters = GeoUtils.distance(wp1.getLat(), wp1.getLng(), wp2.getLat(), wp2.getLng());
return (meters >= 5 || (turn > 10 && meters > 3.0));
}
}