/*
* Autopsy Forensic Browser
*
* Copyright 2016 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.detailview;
import com.google.common.collect.Iterables;
import com.google.common.collect.Range;
import com.google.common.collect.TreeRangeMap;
import java.util.Arrays;
import java.util.Collection;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import javafx.application.Platform;
import javafx.beans.InvalidationListener;
import javafx.beans.Observable;
import javafx.beans.property.ReadOnlyDoubleProperty;
import javafx.beans.property.ReadOnlyDoubleWrapper;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.Insets;
import javafx.scene.Cursor;
import javafx.scene.Group;
import javafx.scene.Scene;
import javafx.scene.chart.Axis;
import javafx.scene.chart.XYChart;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.Tooltip;
import javafx.scene.input.MouseEvent;
import static javafx.scene.layout.Region.USE_PREF_SIZE;
import org.joda.time.DateTime;
import org.sleuthkit.autopsy.coreutils.ThreadConfined;
import org.sleuthkit.autopsy.timeline.TimeLineController;
import org.sleuthkit.autopsy.timeline.datamodel.EventCluster;
import org.sleuthkit.autopsy.timeline.datamodel.EventStripe;
import org.sleuthkit.autopsy.timeline.datamodel.SingleEvent;
import org.sleuthkit.autopsy.timeline.datamodel.TimeLineEvent;
import org.sleuthkit.autopsy.timeline.filters.AbstractFilter;
import org.sleuthkit.autopsy.timeline.filters.DescriptionFilter;
import org.sleuthkit.autopsy.timeline.ui.AbstractTimelineChart;
import org.sleuthkit.autopsy.timeline.ui.ContextMenuProvider;
/**
* One "lane" of a the details view, contains all the core logic and layout
* code.
*
* NOTE: It was too hard to control the threading of this chart via the
* complicated default listeners. Instead clients should use
* addDataItem(javafx.scene.chart.XYChart.Data) and
* removeDataItem(javafx.scene.chart.XYChart.Data) to add and remove data.
*/
abstract class DetailsChartLane<Y extends TimeLineEvent> extends XYChart<DateTime, Y> implements ContextMenuProvider {
private static final String STYLE_SHEET = GuideLine.class.getResource("EventsDetailsChart.css").toExternalForm(); //NON-NLS
static final int MINIMUM_EVENT_NODE_GAP = 4;
static final int MINIMUM_ROW_HEIGHT = 24;
private final DetailsChart parentChart;
private final TimeLineController controller;
private final DetailsChartLayoutSettings layoutSettings;
private final ObservableList<EventNodeBase<?>> selectedNodes;
private final Map<Y, EventNodeBase<?>> eventMap = new HashMap<>();
@ThreadConfined(type = ThreadConfined.ThreadType.JFX)
final ObservableList< EventNodeBase<?>> nodes = FXCollections.observableArrayList();
final ObservableList< EventNodeBase<?>> sortedNodes = nodes.sorted(Comparator.comparing(EventNodeBase::getStartMillis));
private final boolean useQuickHideFilters;
@ThreadConfined(type = ThreadConfined.ThreadType.JFX)//at start of layout pass
private double descriptionWidth;
@ThreadConfined(type = ThreadConfined.ThreadType.JFX)//at start of layout pass
private Set<String> activeQuickHidefilters = new HashSet<>();
boolean quickHideFiltersEnabled() {
return useQuickHideFilters;
}
public void clearContextMenu() {
parentChart.clearContextMenu();
}
@Override
public ContextMenu getContextMenu(MouseEvent clickEvent) {
return parentChart.getContextMenu(clickEvent);
}
EventNodeBase<?> createNode(DetailsChartLane<?> chart, TimeLineEvent event) {
if (event.getEventIDs().size() == 1) {
return new SingleEventNode(this, controller.getEventsModel().getEventById(Iterables.getOnlyElement(event.getEventIDs())), null);
} else if (event instanceof SingleEvent) {
return new SingleEventNode(chart, (SingleEvent) event, null);
} else if (event instanceof EventCluster) {
return new EventClusterNode(chart, (EventCluster) event, null);
} else {
return new EventStripeNode(chart, (EventStripe) event, null);
}
}
@Override
synchronized protected void layoutPlotChildren() {
setCursor(Cursor.WAIT);
if (useQuickHideFilters) {
//These don't change during a layout pass and are expensive to compute per node. So we do it once at the start
activeQuickHidefilters = getController().getQuickHideFilters().stream()
.filter(AbstractFilter::isActive)
.map(DescriptionFilter::getDescription)
.collect(Collectors.toSet());
}
//This dosn't change during a layout pass and is expensive to compute per node. So we do it once at the start
descriptionWidth = layoutSettings.getTruncateAll() ? layoutSettings.getTruncateWidth() : USE_PREF_SIZE;
if (layoutSettings.getBandByType()) {
maxY.set(0);
sortedNodes.stream()
.collect(Collectors.groupingBy(EventNodeBase<?>::getEventType)).values()
.forEach(inputNodes -> maxY.set(layoutEventBundleNodes(inputNodes, maxY.get())));
} else {
maxY.set(layoutEventBundleNodes(sortedNodes, 0));
}
doAdditionalLayout();
setCursor(null);
}
public TimeLineController getController() {
return controller;
}
public ObservableList<EventNodeBase<?>> getSelectedNodes() {
return selectedNodes;
}
/**
* listener that triggers chart layout pass
*/
final InvalidationListener layoutInvalidationListener = (Observable o) -> {
layoutPlotChildren();
};
public ReadOnlyDoubleProperty maxVScrollProperty() {
return maxY.getReadOnlyProperty();
}
/**
* the maximum y value used so far during the most recent layout pass
*/
private final ReadOnlyDoubleWrapper maxY = new ReadOnlyDoubleWrapper(0.0);
DetailsChartLane(DetailsChart parentChart, Axis<DateTime> dateAxis, Axis<Y> verticalAxis, boolean useQuickHideFilters) {
super(dateAxis, verticalAxis);
this.parentChart = parentChart;
this.layoutSettings = parentChart.getLayoutSettings();
this.controller = parentChart.getController();
this.selectedNodes = parentChart.getSelectedNodes();
this.useQuickHideFilters = useQuickHideFilters;
//add a dummy series or the chart is never rendered
setData(FXCollections.observableList(Arrays.asList(new Series<DateTime, Y>())));
Tooltip.install(this, AbstractTimelineChart.getDefaultTooltip());
dateAxis.setAutoRanging(false);
setLegendVisible(false);
setPadding(Insets.EMPTY);
setAlternativeColumnFillVisible(true);
sceneProperty().addListener(observable -> {
Scene scene = getScene();
if (scene != null && scene.getStylesheets().contains(STYLE_SHEET) == false) {
scene.getStylesheets().add(STYLE_SHEET);
}
});
//add listener for events that should trigger layout
layoutSettings.bandByTypeProperty().addListener(layoutInvalidationListener);
layoutSettings.oneEventPerRowProperty().addListener(layoutInvalidationListener);
layoutSettings.truncateAllProperty().addListener(layoutInvalidationListener);
layoutSettings.truncateAllProperty().addListener(layoutInvalidationListener);
layoutSettings.descrVisibilityProperty().addListener(layoutInvalidationListener);
controller.getQuickHideFilters().addListener(layoutInvalidationListener);
//all nodes are added to nodeGroup to facilitate scrolling rather than to getPlotChildren() directly
getPlotChildren().add(nodeGroup);
}
/**
* Layout the nodes in the given list, starting form the given minimum y
* coordinate via the following algorithm:
*
* We start with a list of nodes (each representing an event) sorted by span
* start time of the underlying event
*
* - initialize empty map (maxXatY) from y-ranges to max used x-value
*
* - for each node:
*
* -- size the node based on its children (use this algorithm recursively)
*
* -- get the event's start position from the dateaxis
*
* -- to position node: check if maxXatY is to the left of the left x coord:
* if maxXatY is less than the left x coord, good, put the current node
* here, mark right x coord as maxXatY, go to next node ; if maxXatY is
* greater than the left x coord, increment y position, do check again until
* maxXatY less than left x coord.
*
* @param nodes collection of nodes to layout, sorted by event start time
* @param minY the minimum y coordinate to position the nodes at.
*
* @return the maximum y coordinate used by any of the layed out nodes.
*/
public double layoutEventBundleNodes(final Collection<? extends EventNodeBase<?>> nodes, final double minY) {
// map from y-ranges to maximum x
TreeRangeMap<Double, Double> maxXatY = TreeRangeMap.create();
// maximum y values occupied by any of the given nodes, updated as nodes are layed out.
double localMax = minY;
//for each node do a recursive layout to size it and then position it in first available slot
for (EventNodeBase<?> bundleNode : nodes) {
if (useQuickHideFilters && activeQuickHidefilters.contains(bundleNode.getDescription())) {
//if the node hiden is hidden by quick hide filter, hide it and skip layout
bundleNode.setVisible(false);
bundleNode.setManaged(false);
} else {
layoutBundleHelper(bundleNode);
//get computed height and width
double h = bundleNode.getBoundsInLocal().getHeight();
double w = bundleNode.getBoundsInLocal().getWidth();
//get left and right x coords from axis plus computed width
double xLeft = getXForEpochMillis(bundleNode.getStartMillis()) - bundleNode.getLayoutXCompensation();
double xRight = xLeft + w + MINIMUM_EVENT_NODE_GAP;
//initial test position
double yTop = (layoutSettings.getOneEventPerRow())
? (localMax + MINIMUM_EVENT_NODE_GAP)// if onePerRow, just put it at end
: computeYTop(minY, h, maxXatY, xLeft, xRight);
localMax = Math.max(yTop + h, localMax);
//animate node to new position
bundleNode.animateTo(xLeft, yTop);
}
}
return localMax; //return new max
}
@Override
final public void requestChartLayout() {
super.requestChartLayout();
}
double getXForEpochMillis(Long millis) {
DateTime dateTime = new DateTime(millis);
return getXAxis().getDisplayPosition(dateTime);
}
@Deprecated
@Override
protected void dataItemAdded(Series<DateTime, Y> series, int itemIndex, Data<DateTime, Y> item) {
}
@Deprecated
@Override
protected void dataItemRemoved(Data<DateTime, Y> item, Series<DateTime, Y> series) {
}
@Deprecated
@Override
protected void dataItemChanged(Data<DateTime, Y> item) {
}
@Deprecated
@Override
protected void seriesAdded(Series<DateTime, Y> series, int seriesIndex) {
}
@Deprecated
@Override
protected void seriesRemoved(Series<DateTime, Y> series) {
}
/**
* add an event to this chart
*
* @see note in main section of class JavaDoc
*
* @param event
*/
void addEvent(Y event) {
EventNodeBase<?> eventNode = createNode(this, event);
eventMap.put(event, eventNode);
Platform.runLater(() -> {
nodes.add(eventNode);
nodeGroup.getChildren().add(eventNode);
});
}
/**
* remove an event from this chart
*
* @see note in main section of class JavaDoc
*
* @param event
*/
void removeEvent(Y event) {
EventNodeBase<?> removedNode = eventMap.remove(event);
Platform.runLater(() -> {
nodes.remove(removedNode);
nodeGroup.getChildren().removeAll(removedNode);
});
}
/**
* the group that all event nodes are added to. This facilitates scrolling
* by allowing a single translation of this group.
*/
final Group nodeGroup = new Group();
public synchronized void setVScroll(double vScrollValue) {
nodeGroup.setTranslateY(-vScrollValue);
}
/**
* @return all the nodes that pass the given predicate
*/
synchronized Iterable<EventNodeBase<?>> getAllNodes() {
return getNodes((x) -> true);
}
/**
* @return all the nodes that pass the given predicate
*/
synchronized Iterable<EventNodeBase<?>> getNodes(Predicate<EventNodeBase<?>> p) {
//use this recursive function to flatten the tree of nodes into an single stream.
Function<EventNodeBase<?>, Stream<EventNodeBase<?>>> stripeFlattener
= new Function<EventNodeBase<?>, Stream<EventNodeBase<?>>>() {
@Override
public Stream<EventNodeBase<?>> apply(EventNodeBase<?> node) {
return Stream.concat(
Stream.of(node),
node.getSubNodes().stream().flatMap(this::apply));
}
};
return sortedNodes.stream()
.flatMap(stripeFlattener)
.filter(p).collect(Collectors.toList());
}
/**
* Given information about the current layout pass so far and about a
* particular node, compute the y position of that node.
*
*
* @param yMin the smallest (towards the top of the screen) y position to
* consider
* @param h the height of the node we are trying to position
* @param maxXatY a map from y ranges to the max x within that range. NOTE:
* This map will be updated to include the node in question.
* @param xLeft the left x-cord of the node to position
* @param xRight the left x-cord of the node to position
*
* @return the y position for the node in question.
*
*
*/
double computeYTop(double yMin, double h, TreeRangeMap<Double, Double> maxXatY, double xLeft, double xRight) {
double yTop = yMin;
double yBottom = yTop + h;
//until the node is not overlapping any others try moving it down.
boolean overlapping = true;
while (overlapping) {
overlapping = false;
//check each pixel from bottom to top.
for (double y = yBottom; y >= yTop; y -= MINIMUM_ROW_HEIGHT) {
final Double maxX = maxXatY.get(y);
if (maxX != null && maxX >= xLeft - MINIMUM_EVENT_NODE_GAP) {
//if that pixel is already used
//jump top to this y value and repeat until free slot is found.
overlapping = true;
yTop = y + MINIMUM_EVENT_NODE_GAP;
yBottom = yTop + h;
break;
}
}
}
maxXatY.put(Range.closed(yTop, yBottom), xRight);
return yTop;
}
/**
* Set layout parameters on the given node and layout its children
*
* @param eventNode the Node to layout
*/
void layoutBundleHelper(final EventNodeBase< ?> eventNode) {
//make sure it is shown
eventNode.setVisible(true);
eventNode.setManaged(true);
//apply advanced layout description visibility options
eventNode.setDescriptionVisibility(layoutSettings.getDescrVisibility());
eventNode.setMaxDescriptionWidth(descriptionWidth);
//do recursive layout
eventNode.layoutChildren();
}
abstract void doAdditionalLayout();
DetailsChart getParentChart() {
return parentChart;
}
}