/***********************************************************************************
*
* Copyright (c) 2013-2015 Jason Winnebeck, Kamil Baczkowicz
*
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Apache License Version 2.0 which
* accompany this distribution.
*
* The Apache License Version 2.0 is available 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.
*
* Contributors:
*
* Kamil Baczkowicz - initial API and implementation and/or initial documentation;
* partially derivative work created from the JFXUtils examples (https://github.com/gillius/jfxutils).
*
*/
package pl.baczkowicz.mqttspy.ui;
import java.io.File;
import java.io.IOException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.ResourceBundle;
import javax.imageio.ImageIO;
import javafx.application.Platform;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.embed.swing.SwingFXUtils;
import javafx.event.EventHandler;
import javafx.fxml.FXML;
import javafx.fxml.Initializable;
import javafx.scene.SnapshotParameters;
import javafx.scene.chart.LineChart;
import javafx.scene.chart.NumberAxis;
import javafx.scene.chart.XYChart;
import javafx.scene.chart.XYChart.Data;
import javafx.scene.chart.XYChart.Series;
import javafx.scene.control.Button;
import javafx.scene.control.CheckBox;
import javafx.scene.control.CheckMenuItem;
import javafx.scene.control.ComboBox;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.control.ListView;
import javafx.scene.control.Menu;
import javafx.scene.control.MenuButton;
import javafx.scene.control.TextInputDialog;
import javafx.scene.control.Tooltip;
import javafx.scene.image.WritableImage;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.AnchorPane;
import javafx.stage.FileChooser;
import javafx.util.Callback;
import javafx.util.StringConverter;
import org.gillius.jfxutils.chart.ChartPanManager;
import org.gillius.jfxutils.chart.JFXChartUtil;
import org.gillius.jfxutils.chart.StableTicksAxis;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import pl.baczkowicz.mqttspy.connectivity.MqttSubscription;
import pl.baczkowicz.mqttspy.ui.charts.ChartMode;
import pl.baczkowicz.mqttspy.ui.utils.StylingUtils;
import pl.baczkowicz.spy.eventbus.IKBus;
import pl.baczkowicz.spy.messages.FormattedMessage;
import pl.baczkowicz.spy.ui.events.MessageAddedEvent;
import pl.baczkowicz.spy.ui.events.queuable.ui.BrowseReceivedMessageEvent;
import pl.baczkowicz.spy.ui.properties.MessageLimitProperties;
import pl.baczkowicz.spy.ui.storage.BasicMessageStoreWithSummary;
import pl.baczkowicz.spy.ui.utils.DialogFactory;
import pl.baczkowicz.spy.utils.TimeUtils;
/**
* Controller for line chart pane.
*/
public class LineChartPaneController<T extends FormattedMessage> implements Initializable
{
/** Diagnostic logger. */
private final static Logger logger = LoggerFactory.getLogger(LineChartPaneController.class);
private static boolean lastAutoRefresh = true;
private static boolean lastDisplaySymbols = true;
private static MessageLimitProperties lastMessageLimit
= new MessageLimitProperties("50 messages", 50, 0);
@FXML
private AnchorPane chartPane;
@FXML
private Label showRangeLabel;
@FXML
private ComboBox<MessageLimitProperties> showRangeBox;
@FXML
private Button refreshButton;
@FXML
private CheckBox autoRefreshCheckBox;
@FXML
private CheckMenuItem displaySymbolsCheckBox;
@FXML
private MenuButton optionsButton;
@FXML
private CheckMenuItem saveImageOnMessage;
@FXML
private CheckMenuItem saveImageAtInterval;
@FXML
private CheckMenuItem addTimestampOnExport;
@FXML
private Menu autoImageExport;
private BasicMessageStoreWithSummary<T> store;
private IKBus eventBus;
private MqttSubscription subscription;
private Collection<String> topics;
private Map<String, List<FormattedMessage>> chartData = new HashMap<>();
private Map<String, Series<Number, Number>> topicToSeries = new LinkedHashMap<>();
private LineChart<Number, Number> lineChart;
private boolean warningShown;
private String seriesTypeName;
private ChartMode chartMode;
private String seriesValueName;
private String seriesUnit;
private File selectedImageFile;
private boolean saveOnMessage;
private Integer exportInterval;
private boolean addTimestampOnAutoExport;
/**
* @param seriesValueName the seriesValueName to set
*/
public void setSeriesValueName(String seriesValueName)
{
this.seriesValueName = seriesValueName;
}
public void initialize(URL location, ResourceBundle resources)
{
autoRefreshCheckBox.selectedProperty().addListener(new ChangeListener<Boolean>()
{
@Override
public void changed(ObservableValue<? extends Boolean> observable, Boolean oldValue, Boolean newValue)
{
lastAutoRefresh = autoRefreshCheckBox.isSelected();
// Only refresh when auto-refresh enabled
if (lastAutoRefresh)
{
refresh();
}
}
});
displaySymbolsCheckBox.selectedProperty().addListener(new ChangeListener<Boolean>()
{
@Override
public void changed(ObservableValue<? extends Boolean> observable, Boolean oldValue, Boolean newValue)
{
lastDisplaySymbols = displaySymbolsCheckBox.isSelected();
refresh();
}
});
showRangeBox.getSelectionModel().selectedIndexProperty().addListener(new ChangeListener<Number>()
{
@Override
public void changed(ObservableValue<? extends Number> observable, Number oldValue, Number newValue)
{
lastMessageLimit = showRangeBox.getValue();
refresh();
}
});
// Axis and chart
final NumberAxis xAxis = new NumberAxis();
final StableTicksAxis yAxis = new StableTicksAxis();
xAxis.setForceZeroInRange(false);
xAxis.setTickLabelFormatter(new StringConverter<Number>()
{
@Override
public String toString(Number object)
{
final Date date = new Date(object.longValue());
return TimeUtils.TIME_SDF.format(date);
}
@Override
public Number fromString(String string)
{
return null;
}
});
yAxis.setForceZeroInRange(false);
lineChart = new LineChart<>(xAxis, yAxis);
}
public void init()
{
showRangeBox.setCellFactory(new Callback<ListView<MessageLimitProperties>, ListCell<MessageLimitProperties>>()
{
@Override
public ListCell<MessageLimitProperties> call(ListView<MessageLimitProperties> l)
{
return new ListCell<MessageLimitProperties>()
{
@Override
protected void updateItem(MessageLimitProperties item, boolean empty)
{
super.updateItem(item, empty);
if (item == null || empty)
{
setText(null);
}
else
{
setText(item.getName());
}
}
};
}
});
showRangeBox.setConverter(new StringConverter<MessageLimitProperties>()
{
@Override
public String toString(MessageLimitProperties item)
{
if (item == null)
{
return null;
}
else
{
return item.getName();
}
}
@Override
public MessageLimitProperties fromString(String id)
{
return null;
}
});
showRangeBox.getItems().clear();
showRangeBox.getItems().add(new MessageLimitProperties("All", 0, 0));
showRangeBox.getItems().add(new MessageLimitProperties("10 messages", 10, 0));
showRangeBox.getItems().add(new MessageLimitProperties("50 messages", 50, 0));
showRangeBox.getItems().add(new MessageLimitProperties("100 messages", 100, 0));
showRangeBox.getItems().add(new MessageLimitProperties("1k messages", 1000, 0));
showRangeBox.getItems().add(new MessageLimitProperties("10k messages", 10000, 0));
showRangeBox.getItems().add(new MessageLimitProperties("1 minute", 0, TimeUtils.ONE_MINUTE));
showRangeBox.getItems().add(new MessageLimitProperties("5 minutes", 0, 5 * TimeUtils.ONE_MINUTE));
showRangeBox.getItems().add(new MessageLimitProperties("30 minutes", 0, 30 * TimeUtils.ONE_MINUTE));
showRangeBox.getItems().add(new MessageLimitProperties("1 hour", 0, TimeUtils.ONE_HOUR));
showRangeBox.getItems().add(new MessageLimitProperties("24 hours", 0, TimeUtils.ONE_DAY));
showRangeBox.getItems().add(new MessageLimitProperties("48 hours", 0, 2 * TimeUtils.ONE_DAY));
showRangeBox.getItems().add(new MessageLimitProperties("1 week", 0, 7 * TimeUtils.ONE_DAY));
if (subscription != null)
{
refreshButton.setStyle(StylingUtils.createBaseRGBString(subscription.getColor()));
}
chartPane.getChildren().add(lineChart);
AnchorPane.setBottomAnchor(lineChart, 0.0);
AnchorPane.setLeftAnchor(lineChart, 0.0);
AnchorPane.setTopAnchor(lineChart, 45.0);
AnchorPane.setRightAnchor(lineChart, 0.0);
if (ChartMode.USER_DRIVEN_MSG_PAYLOAD.equals(chartMode) || ChartMode.USER_DRIVEN_MSG_SIZE.equals(chartMode))
{
// Selecting a value will perform a refresh
for (final MessageLimitProperties limit : showRangeBox.getItems())
{
if (limit.getMessageLimit() == lastMessageLimit.getMessageLimit()
&& limit.getTimeLimit() == lastMessageLimit.getTimeLimit())
{
showRangeBox.setValue(limit);
}
}
autoRefreshCheckBox.setSelected(lastAutoRefresh);
displaySymbolsCheckBox.setSelected(lastDisplaySymbols);
}
// else if (ChartMode.STATS.equals(chartMode))
// {
// showRangeBox.setVisible(false);
// showRangeLabel.setVisible(false);
// }
eventBus.subscribeWithFilterOnly(this, this::onMessageAdded, MessageAddedEvent.class, store.getMessageList());
// eventManager.registerMessageAddedObserver(this, store.getMessageList());
setupPanAndZoom();
}
/**
* Sets up pan and zoom. Derivative work created from the JFXUtils examples (https://github.com/gillius/jfxutils).
*/
private void setupPanAndZoom()
{
// Panning works via either secondary (right) mouse or primary with ctrl
// held down
ChartPanManager panner = new ChartPanManager(lineChart);
panner.setMouseFilter(new EventHandler<MouseEvent>()
{
@Override
public void handle(MouseEvent mouseEvent)
{
if (mouseEvent.getButton() == MouseButton.SECONDARY
|| (mouseEvent.getButton() == MouseButton.PRIMARY && mouseEvent
.isShortcutDown()))
{
// let it through
}
else
{
mouseEvent.consume();
}
}
});
panner.start();
// Zooming works only via primary mouse button without ctrl held down
JFXChartUtil.setupZooming(lineChart, new EventHandler<MouseEvent>()
{
@Override
public void handle(MouseEvent mouseEvent)
{
if (mouseEvent.getButton() != MouseButton.PRIMARY
|| mouseEvent.isShortcutDown())
mouseEvent.consume();
}
});
// Set up reset on double click
lineChart.setOnMouseClicked(new EventHandler<MouseEvent>()
{
@Override
public void handle(MouseEvent event)
{
if (event.getClickCount() == 2)
{
reset();
}
}
});
}
public void cleanup()
{
eventBus.unsubscribeConsumer(this, MessageAddedEvent.class);
exportInterval = null;
}
private void divideMessagesByTopic(final Collection<String> topics)
{
chartData.clear();
for (final FormattedMessage message : store.getMessages())
{
final String topic = message.getTopic();
// logger.info("Topics = " + topics);
if (topics.contains(topic))
{
createTopicIfDoesNotExist(topic);
chartData.get(topic).add(message);
}
}
}
private void createTopicIfDoesNotExist(final String topic)
{
if (chartData.get(topic) == null)
{
chartData.put(topic, new ArrayList<FormattedMessage>());
}
}
/**
* Reset chart. Derivative work created from the JFXUtils examples (https://github.com/gillius/jfxutils).
*/
@FXML
private void reset()
{
lineChart.getXAxis().setAutoRanging(true);
lineChart.getYAxis().setAutoRanging(true);
// Reset data to get ranging right
final ObservableList<XYChart.Series<Number, Number>> data = lineChart.getData();
lineChart.setData(FXCollections.<XYChart.Series<Number, Number>> emptyObservableList());
lineChart.setData(data);
lineChart.setData(FXCollections.<XYChart.Series<Number, Number>> emptyObservableList());
lineChart.setData(data);
lineChart.setAnimated(true);
}
private XYChart.Data<Number, Number> createDataObject(final FormattedMessage message)
{
if (ChartMode.USER_DRIVEN_MSG_PAYLOAD.equals(chartMode))
{
final Double value = Double.valueOf(message.getFormattedPayload());
return new XYChart.Data<Number, Number>(message.getDate().getTime(), value);
}
else if (ChartMode.USER_DRIVEN_MSG_SIZE.equals(chartMode))
{
final Integer value = Integer.valueOf(message.getPayload().length());
return new XYChart.Data<Number, Number>(message.getDate().getTime(), value);
}
else
{
// Nothing to do for now
}
return null;
}
private void addMessageToSeries(final Series<Number, Number> series, final FormattedMessage message)
{
try
{
series.getData().add(createDataObject(message));
}
catch (NumberFormatException e)
{
if (!warningShown && ChartMode.USER_DRIVEN_MSG_PAYLOAD.equals(chartMode))
{
String payload = message.getFormattedPayload();
if (payload.length() > 25)
{
payload = payload.substring(0, 25) + "...";
}
DialogFactory.createWarningDialog(
"Invalid content",
"Payload \"" + payload
+ "\" on \"" + message.getTopic()
+ "\" cannot be converted to a number - ignoring all invalid values.");
warningShown = true;
}
}
}
@FXML
private void exportAsImage()
{
final FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Select PNG file to save as...");
selectedImageFile = fileChooser.showSaveDialog(lineChart.getScene().getWindow());
if (selectedImageFile != null)
{
exportAsImage(selectedImageFile);
}
}
private void exportAsImage(final File selectedFile)
{
final WritableImage image = lineChart.snapshot(new SnapshotParameters(), null);
try
{
if (addTimestampOnAutoExport)
{
final String newName = selectedFile.getAbsolutePath().replace(
selectedFile.getName(),
TimeUtils.DATE_WITH_SECONDS_FILENAME_SDF.format(new Date()) + "_" + selectedFile.getName());
final File withTimestamp = new File(newName);
ImageIO.write(SwingFXUtils.fromFXImage(image, null), "png", withTimestamp);
}
else
{
ImageIO.write(SwingFXUtils.fromFXImage(image, null), "png", selectedFile);
}
autoImageExport.setDisable(false);
}
catch (IOException e)
{
logger.error("Cannot export to file {}", selectedFile.getAbsoluteFile(), e);
DialogFactory.createErrorDialog("Cannot export to file", "Chart cannot be exported to file: " + e.getLocalizedMessage());
}
}
@FXML
private void updateSaveOnMessage()
{
saveOnMessage = saveImageOnMessage.isSelected();
}
@FXML
private void updateSaveOnInterval()
{
if (saveImageAtInterval.isSelected())
{
final TextInputDialog dialog = new TextInputDialog("60");
dialog.setTitle("Export interval");
dialog.setHeaderText(null);
dialog.setContentText("Please enter export interval (in seconds):");
final Optional<String> result = dialog.showAndWait();
if (result.isPresent())
{
try
{
exportInterval = Integer.valueOf(result.get());
if (exportInterval > 0)
{
new Thread(new Runnable()
{
@Override
public void run()
{
while (exportInterval != null && exportInterval > 0)
{
Platform.runLater(new Runnable()
{
@Override
public void run()
{
exportAsImage(selectedImageFile);
}
});
try
{
Thread.sleep(exportInterval * 1000);
}
catch (InterruptedException e)
{
e.printStackTrace();
}
}
}
}).start();
}
// else if (exportInterval == 0)
// {
// exportInterval = null;
// }
else
{
DialogFactory.createErrorDialog("Invalid number format", "The provided value is not a correct number (>0).");
exportInterval = null;
}
}
catch (NumberFormatException e)
{
DialogFactory.createErrorDialog("Invalid number format", "The provided value is not a correct number (>0).");
}
}
}
else
{
exportInterval = null;
}
}
@FXML
private void addTimestampOnAutoExport()
{
addTimestampOnAutoExport = addTimestampOnExport.isSelected();
}
@FXML
private void refresh()
{
synchronized (chartData)
{
divideMessagesByTopic(topics);
lineChart.getData().clear();
lineChart.setCreateSymbols(lastDisplaySymbols);
topicToSeries.clear();
for (final String topic : topics)
{
final List<FormattedMessage> extractedMessages = new ArrayList<>();
final Series<Number, Number> series = new XYChart.Series<>();
topicToSeries.put(topic, series);
series.setName(topic);
final MessageLimitProperties limit = showRangeBox.getValue();
final int itemsAvailable = chartData.get(topic).size();
// Limit by number
int startIndex = 0;
if (limit.getMessageLimit() > 0 && limit.getMessageLimit() < itemsAvailable)
{
startIndex = itemsAvailable - limit.getMessageLimit();
}
// Limit by time
final Date now = new Date();
for (int i = startIndex; i < itemsAvailable; i++)
{
final FormattedMessage message = chartData.get(topic).get(chartData.get(topic).size() - i - 1);
if (limit.getTimeLimit() > 0 && (message.getDate().getTime() + limit.getTimeLimit() < now.getTime()))
{
continue;
}
extractedMessages.add(message);
addMessageToSeries(series, message);
}
// logger.info("Populated = {}=?{}/{}", chartData.get(topic).size(), topicToSeries.get(topic).getData().size(), limit.getMessageLimit());
// For further processing, take only messages put on chart
chartData.put(topic, extractedMessages);
lineChart.getData().add(series);
populateTooltips(lineChart);
}
}
}
/**
* Populates the tooltip with data (chart-type independent).
*
* @param series The series
* @param data The data
*/
private void populateTooltip(final Series<Number, Number> series, final Data<Number, Number> data)
{
final Date date = new Date();
date.setTime(data.getXValue().longValue());
final Tooltip tooltip = new Tooltip(
seriesTypeName + " = " + series.getName()
+ System.lineSeparator()
+ seriesValueName + " = " + data.getYValue() + " " + seriesUnit
+ System.lineSeparator()
+ "Time = " + TimeUtils.TIME_SDF.format(date));
Tooltip.install(data.getNode(), tooltip);
}
private void populateTooltips(final LineChart<Number, Number> lineChart)
{
for (final Series<Number, Number> series : lineChart.getData())
{
for (final Data<Number, Number> data : series.getData())
{
populateTooltip(series, data);
}
}
}
// TODO: optimise message handling
public void onMessageAdded(final MessageAddedEvent<FormattedMessage> event)
{
for (final BrowseReceivedMessageEvent<FormattedMessage> message : event.getMessages())
{
onMessageAdded(message.getMessage());
}
}
public void onMessageAdded(final FormattedMessage message)
{
// TODO: is that ever deregistered?
synchronized (chartData)
{
final String topic = message.getTopic();
createTopicIfDoesNotExist(topic);
final MessageLimitProperties limit = showRangeBox.getValue();
//logger.info("Message limit = {}", limit.getMessageLimit());
//logger.info("Time limit = {}", limit.getTimeLimit());
if (autoRefreshCheckBox.isSelected() && topics.contains(topic))
{
// Apply message limit
while ((limit.getMessageLimit() > 0) && (chartData.get(topic).size() >= limit.getMessageLimit()))
{
//logger.info("Deleting = {}=?{}/{}", chartData.get(topic).size(), topicToSeries.get(topic).getData().size(), limit.getMessageLimit());
chartData.get(topic).remove(0);
topicToSeries.get(topic).getData().remove(0);
}
// Apply time limit
final Date now = new Date();
if (limit.getTimeLimit() > 0)
{
FormattedMessage oldestMessage = chartData.get(topic).get(0);
while (oldestMessage.getDate().getTime() + limit.getTimeLimit() < now.getTime())
{
chartData.get(topic).remove(0);
topicToSeries.get(topic).getData().remove(0);
if (chartData.get(topic).size() == 0)
{
break;
}
oldestMessage = chartData.get(topic).get(0);
}
}
// Add the new message
chartData.get(topic).add(message);
addMessageToSeries(topicToSeries.get(topic), message);
//logger.info("Added = {}=?{}/{}", chartData.get(topic).size(), topicToSeries.get(topic).getData().size(), limit.getMessageLimit());
if (topicToSeries.get(topic).getData().size() > 0)
{
populateTooltip(topicToSeries.get(topic),
topicToSeries.get(topic).getData().get(topicToSeries.get(topic).getData().size() - 1));
}
saveOnMessage();
}
}
}
private void saveOnMessage()
{
if (saveOnMessage)
{
new Thread(new Runnable()
{
@Override
public void run()
{
try
{
Thread.sleep(1000);
}
catch (InterruptedException e)
{
e.printStackTrace();
}
Platform.runLater(new Runnable()
{
@Override
public void run()
{
exportAsImage(selectedImageFile);
}
});
}
}).start();
}
}
public void setChartMode(final ChartMode mode)
{
this.chartMode = mode;
}
// ===============================
// === Setters and getters =======
// ===============================
public void setTopics(final Collection<String> topics)
{
this.topics = topics;
}
public void setSubscription(MqttSubscription subscription)
{
this.subscription = subscription;
}
public void setStore(final BasicMessageStoreWithSummary<T> store)
{
this.store = store;
}
public void setSeriesTypeName(final String seriesTypeName)
{
this.seriesTypeName = seriesTypeName;
}
public void setSeriesUnit(String seriesUnit)
{
this.seriesUnit = seriesUnit;
}
public void setEventBus(final IKBus eventBus)
{
this.eventBus = eventBus;
}
}