/*
* Autopsy Forensic Browser
*
* Copyright 2014 Basis Technology Corp.
* Contact: carrier <at> sleuthkit <dot> org
*
* 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 org.sleuthkit.autopsy.timeline.ui;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.ExecutionException;
import javafx.beans.InvalidationListener;
import javafx.beans.Observable;
import javafx.beans.property.ReadOnlyListProperty;
import javafx.beans.property.ReadOnlyListWrapper;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.collections.FXCollections;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.concurrent.Task;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.chart.Axis;
import javafx.scene.chart.BarChart;
import javafx.scene.chart.Chart;
import javafx.scene.chart.XYChart;
import javafx.scene.control.Label;
import javafx.scene.control.OverrunStyle;
import javafx.scene.effect.Effect;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.Pane;
import javafx.scene.layout.Region;
import javafx.scene.text.Font;
import javafx.scene.text.FontWeight;
import javafx.scene.text.Text;
import javafx.scene.text.TextAlignment;
import javax.annotation.concurrent.Immutable;
import org.apache.commons.lang3.StringUtils;
import org.openide.util.Exceptions;
import org.sleuthkit.autopsy.timeline.TimeLineController;
import org.sleuthkit.autopsy.timeline.TimeLineView;
import org.sleuthkit.autopsy.timeline.events.FilteredEventsModel;
/** Abstract base class for {@link Chart} based {@link TimeLineView}s used in
* the main visualization area.
*
* @param <X> the type of data plotted along the x axis
* @param <Y> the type of data plotted along the y axis
* @param <N> the type of nodes used to represent data items
* @param <C> the type of the {@link XYChart<X,Y>} this class uses to plot the
* data.
*
* TODO: this is becoming (too?) closely tied to the notion that their is a
* {@link XYChart} doing the rendering. Is this a good idea? -jm
* TODO: pull up common history context menu items out of derived classes? -jm
*/
public abstract class AbstractVisualization<X, Y, N extends Node, C extends XYChart<X, Y> & TimeLineChart<X>> extends BorderPane implements TimeLineView {
protected final SimpleBooleanProperty hasEvents = new SimpleBooleanProperty(true);
protected final ObservableList<BarChart.Series<X, Y>> dataSets = FXCollections.<BarChart.Series<X, Y>>observableArrayList();
protected C chart;
//// replacement axis label componenets
private final Pane leafPane; // container for the leaf lables in the declutterd axis
private final Pane branchPane;// container for the branch lables in the declutterd axis
protected final Region spacer;
/** task used to reload the content of this visualization */
private Task<Boolean> updateTask;
protected TimeLineController controller;
protected FilteredEventsModel filteredEvents;
protected ReadOnlyListWrapper<N> selectedNodes = new ReadOnlyListWrapper<>(FXCollections.observableArrayList());
public ReadOnlyListProperty<N> getSelectedNodes() {
return selectedNodes.getReadOnlyProperty();
}
/** list of {@link Node}s to insert into the toolbar. This should be
* set in an implementations constructor. */
protected List<Node> settingsNodes;
/** @return the list of nodes containing settings widgets to insert into
* this visualization's header */
protected List<Node> getSettingsNodes() {
return Collections.unmodifiableList(settingsNodes);
}
/** @param value a value along this visualization's x axis
*
* @return true if the tick label for the given value should be bold ( has
* relevant data),
* false* otherwise */
protected abstract Boolean isTickBold(X value);
/** apply this visualization's 'selection effect' to the given node
*
* @param node the node to apply the 'effect' to
* @param applied true if the effect should be applied, false if the effect
* should
*/
protected abstract void applySelectionEffect(N node, Boolean applied);
/** @return a task to execute on a background thread to reload this
* visualization with different data. */
protected abstract Task<Boolean> getUpdateTask();
/** @return return the {@link Effect} applied to 'selected nodes' in this
* visualization, or null if selection is visualized via another
* mechanism */
protected abstract Effect getSelectionEffect();
/** @param tickValue
*
* @return a String to use for a tick mark label given a tick value
*/
protected abstract String getTickMarkLabel(X tickValue);
/** the spacing (in pixels) between tick marks of the horizontal
* axis. This will be used to layout the decluttered replacement labels.
*
* @return the spacing in pixels between tick marks of the horizontal
* axis */
protected abstract double getTickSpacing();
/** @return the horizontal axis used by this Visualization's chart */
protected abstract Axis<X> getXAxis();
/** @return the vertical axis used by this Visualization's chart */
protected abstract Axis<Y> getYAxis();
/** * update this visualization based on current state of zoom /
* filters.Primarily this invokes the background {@link Task} returned
* by {@link #getUpdateTask()} which derived classes must implement. */
synchronized public void update() {
if (updateTask != null) {
updateTask.cancel(true);
updateTask = null;
}
updateTask = getUpdateTask();
updateTask.stateProperty().addListener((Observable observable) -> {
switch (updateTask.getState()) {
case CANCELLED:
case FAILED:
case READY:
case RUNNING:
case SCHEDULED:
break;
case SUCCEEDED:
try {
this.hasEvents.set(updateTask.get());
} catch (InterruptedException | ExecutionException ex) {
Exceptions.printStackTrace(ex);
}
break;
}
});
controller.monitorTask(updateTask);
}
synchronized public void dispose() {
if (updateTask != null) {
updateTask.cancel(true);
}
this.filteredEvents.getRequestedZoomParamters().removeListener(invalidationListener);
invalidationListener = null;
}
protected AbstractVisualization(Pane partPane, Pane contextPane, Region spacer) {
this.leafPane = partPane;
this.branchPane = contextPane;
this.spacer = spacer;
selectedNodes.addListener((ListChangeListener.Change<? extends N> c) -> {
while (c.next()) {
c.getRemoved().forEach((N n) -> {
applySelectionEffect(n, false);
});
c.getAddedSubList().forEach((N c1) -> {
applySelectionEffect(c1, true);
});
}
});
}
@Override
synchronized public void setController(TimeLineController controller) {
this.controller = controller;
chart.setController(controller);
setModel(controller.getEventsModel());
TimeLineController.getTimeZone().addListener((Observable observable) -> {
update();
});
}
@Override
synchronized public void setModel(FilteredEventsModel filteredEvents) {
this.filteredEvents = filteredEvents;
this.filteredEvents.getRequestedZoomParamters().addListener(invalidationListener);
update();
}
protected InvalidationListener invalidationListener = (Observable observable) -> {
update();
};
/** iterate through the list of tick-marks building a two level structure of
* replacement tick marl labels. (Visually) upper level has most
* detailed/highest frequency part of date/time. Second level has rest of
* date/time grouped by unchanging part.
* eg:
*
*
* october-30_october-31_september-01_september-02_september-03
*
* becomes
*
* _________30_________31___________01___________02___________03
*
* _________october___________|_____________september___________
*
*
* NOTE: This method should only be invoked on the JFX thread
*/
public synchronized void layoutDateLabels() {
//clear old labels
branchPane.getChildren().clear();
leafPane.getChildren().clear();
//since the tickmarks aren't necessarily in value/position order,
//make a clone of the list sorted by position along axis
ObservableList<Axis.TickMark<X>> tickMarks = FXCollections.observableArrayList(getXAxis().getTickMarks());
tickMarks.sort((Axis.TickMark<X> t, Axis.TickMark<X> t1) -> Double.compare(t.getPosition(), t1.getPosition()));
if (tickMarks.isEmpty() == false) {
//get the spacing between ticks in the underlying axis
double spacing = getTickSpacing();
//initialize values from first tick
TwoPartDateTime dateTime = new TwoPartDateTime(getTickMarkLabel(tickMarks.get(0).getValue()));
String lastSeenBranchLabel = dateTime.branch;
//cumulative width of the current branch label
//x-positions (pixels) of the current branch and leaf labels
double leafLabelX = 0;
if (dateTime.branch.equals("")) {
//if there is only one part to the date (ie only year), just add a label for each tick
for (Axis.TickMark<X> t : tickMarks) {
assignLeafLabel(new TwoPartDateTime(getTickMarkLabel(t.getValue())).leaf,
spacing,
leafLabelX,
isTickBold(t.getValue())
);
leafLabelX += spacing; //increment x
}
} else {
//there are two parts so ...
//initialize additional state
double branchLabelX = 0;
double branchLabelWidth = 0;
for (Axis.TickMark<X> t : tickMarks) { //for each tick
//split the label into a TwoPartDateTime
dateTime = new TwoPartDateTime(getTickMarkLabel(t.getValue()));
//if we are still on the same branch
if (lastSeenBranchLabel.equals(dateTime.branch)) {
//increment branch width
branchLabelWidth += spacing;
} else {// we are on to a new branch, so ...
assignBranchLabel(lastSeenBranchLabel, branchLabelWidth, branchLabelX);
//and then update label, x-pos, and width
lastSeenBranchLabel = dateTime.branch;
branchLabelX += branchLabelWidth;
branchLabelWidth = spacing;
}
//add the label for the leaf (highest frequency part)
assignLeafLabel(dateTime.leaf, spacing, leafLabelX, isTickBold(t.getValue()));
//increment leaf position
leafLabelX += spacing;
}
//we have reached end so add branch label for current branch
assignBranchLabel(lastSeenBranchLabel, branchLabelWidth, branchLabelX);
}
}
//request layout since we have modified scene graph structure
requestParentLayout();
}
protected void setChartClickHandler() {
chart.addEventHandler(MouseEvent.MOUSE_CLICKED, (MouseEvent event) -> {
if (event.getButton() == MouseButton.PRIMARY && event.isStillSincePress()) {
selectedNodes.clear();
}
});
}
/** add a {@link Text} node to the leaf container for the decluttered axis
* labels
*
* @param labelText the string to add
* @param labelWidth the width of the space available for the text
* @param labelX the horizontal position in the partPane of the text
* @param bold true if the text should be bold, false otherwise
*/
private synchronized void assignLeafLabel(String labelText, double labelWidth, double labelX, boolean bold) {
Text label = new Text(" " + labelText + " ");
label.setTextAlignment(TextAlignment.CENTER);
label.setFont(Font.font(null, bold ? FontWeight.BOLD : FontWeight.NORMAL, 10));
//position label accounting for width
label.relocate(labelX + labelWidth / 2 - label.getBoundsInLocal().getWidth() / 2, 0);
label.autosize();
if (leafPane.getChildren().isEmpty()) {
//just add first label
leafPane.getChildren().add(label);
} else {
//otherwise don't actually add the label if it would intersect with previous label
final Text lastLabel = (Text) leafPane.getChildren().get(leafPane.getChildren().size() - 1);
if (!lastLabel.getBoundsInParent().intersects(label.getBoundsInParent())) {
leafPane.getChildren().add(label);
}
}
}
/** add a {@link Label} node to the branch container for the decluttered
* axis labels
*
* @param labelText the string to add
* @param labelWidth the width of the space to use for the label
* @param labelX the horizontal position in the partPane of the text
*/
private synchronized void assignBranchLabel(String labelText, double labelWidth, double labelX) {
Label label = new Label(labelText);
label.setAlignment(Pos.CENTER);
label.setTextAlignment(TextAlignment.CENTER);
label.setFont(Font.font(10));
//use a leading ellipse since that is the lowest frequency part,
//and can be infered more easily from other surrounding labels
label.setTextOverrun(OverrunStyle.LEADING_ELLIPSIS);
//force size
label.setMinWidth(labelWidth);
label.setPrefWidth(labelWidth);
label.setMaxWidth(labelWidth);
label.relocate(labelX, 0);
if (labelX == 0) { // first label has no border
label.setStyle("-fx-border-width: 0 0 0 0 ; -fx-border-color:black;");
} else { // subsequent labels have border on left to create dividers
label.setStyle("-fx-border-width: 0 0 0 1; -fx-border-color:black;");
}
branchPane.getChildren().add(label);
}
/** A simple data object used to represent a partial date as up to two
* parts. A low frequency part (branch) containing all but the most
* specific element, and a highest frequency part (leaf) containing the most
* specific element. The branch and leaf names come from thinking of the
* space of all date times as a tree with higher frequency information
* further from the root. If there is only one part, it will be in the
* branch and the leaf will equal an empty string */
@Immutable
private static final class TwoPartDateTime {
/** the low frequency part of a date/time eg 2001-May-4 */
private final String branch;
/** the highest frequency part of a date/time eg 14 (2pm) */
private final String leaf;
TwoPartDateTime(String dateString) {
//find index of separator to spit on
int splitIndex = StringUtils.lastIndexOfAny(dateString, " ", "-", ":");
if (splitIndex < 0) { // there is only one part
leaf = dateString;
branch = "";
} else { //split at index
leaf = StringUtils.substring(dateString, splitIndex + 1);
branch = StringUtils.substring(dateString, 0, splitIndex);
}
}
}
}