/*
* Autopsy Forensic Browser
*
* Copyright 2011-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;
import java.util.Comparator;
import java.util.HashMap;
import java.util.Map;
import javafx.application.Platform;
import javafx.beans.binding.DoubleBinding;
import javafx.collections.FXCollections;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.collections.transformation.SortedList;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.chart.Axis;
import javafx.scene.chart.XYChart;
import javafx.scene.control.Label;
import javafx.scene.control.OverrunStyle;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.Border;
import javafx.scene.layout.BorderStroke;
import javafx.scene.layout.BorderStrokeStyle;
import javafx.scene.layout.BorderWidths;
import javafx.scene.layout.CornerRadii;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Pane;
import javafx.scene.layout.Region;
import javafx.scene.layout.VBox;
import javafx.scene.paint.Color;
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.NbBundle;
import org.sleuthkit.autopsy.coreutils.Logger;
import org.sleuthkit.autopsy.coreutils.ThreadConfined;
import org.sleuthkit.autopsy.timeline.TimeLineController;
import org.sleuthkit.autopsy.timeline.datamodel.eventtype.EventType;
/**
* Abstract base class for TimeLineChart based views.
*
* @param <X> The type of data plotted along the x axis
* @param <Y> The type of data plotted along the y axis
* @param <NodeType> The type of nodes used to represent data items
* @param <ChartType> The type of the TimeLineChart<X> this class uses to plot
* the data. Must extend Region.
*
* TODO: this is becoming (too?) closely tied to the notion that their is a
* 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 AbstractTimelineChart<X, Y, NodeType extends Node, ChartType extends Region & TimeLineChart<X>> extends AbstractTimeLineView {
private static final Logger LOGGER = Logger.getLogger(AbstractTimelineChart.class.getName());
@NbBundle.Messages("AbstractTimelineChart.defaultTooltip.text=Drag the mouse to select a time interval to zoom into.\nRight-click for more actions.")
private static final Tooltip DEFAULT_TOOLTIP = new Tooltip(Bundle.AbstractTimelineChart_defaultTooltip_text());
private static final Border ONLY_LEFT_BORDER = new Border(new BorderStroke(Color.BLACK, BorderStrokeStyle.SOLID, CornerRadii.EMPTY, new BorderWidths(0, 0, 0, 1)));
/**
* Get the tool tip to use for this view when no more specific Tooltip is
* needed.
*
* @return The default Tooltip.
*/
static public Tooltip getDefaultTooltip() {
return DEFAULT_TOOLTIP;
}
/**
* The nodes that are selected.
*
* @return An ObservableList<NodeType> of the nodes that are selected in
* this view.
*/
protected ObservableList<NodeType> getSelectedNodes() {
return selectedNodes;
}
/**
* Access to chart data via series
*/
protected final ObservableList<XYChart.Series<X, Y>> dataSeries = FXCollections.<XYChart.Series<X, Y>>observableArrayList();
protected final Map<EventType, XYChart.Series<X, Y>> eventTypeToSeriesMap = new HashMap<>();
private ChartType chart;
//// replacement axis label componenets
private final Pane specificLabelPane = new Pane(); // container for the specfic labels in the decluttered axis
private final Pane contextLabelPane = new Pane();// container for the contextual labels in the decluttered axis
// container for the contextual labels in the decluttered axis
private final Region spacer = new Region();
final private ObservableList<NodeType> selectedNodes = FXCollections.observableArrayList();
public Pane getSpecificLabelPane() {
return specificLabelPane;
}
public Pane getContextLabelPane() {
return contextLabelPane;
}
public Region getSpacer() {
return spacer;
}
/**
* Get the CharType that implements this view.
*
* @return The CharType that implements this view.
*/
protected ChartType getChart() {
return chart;
}
/**
* Set the ChartType that implements this view.
*
* @param chart The ChartType that implements this view.
*/
@ThreadConfined(type = ThreadConfined.ThreadType.JFX)
protected void setChart(ChartType chart) {
this.chart = chart;
setCenter(chart);
}
/**
* Apply this view's 'selection effect' to the given node.
*
* @param node The node to apply the 'effect' to.
*/
protected void applySelectionEffect(NodeType node) {
applySelectionEffect(node, true);
}
/**
* Remove this view's 'selection effect' from the given node.
*
* @param node The node to remvoe the 'effect' from.
*/
protected void removeSelectionEffect(NodeType node) {
applySelectionEffect(node, Boolean.FALSE);
}
/**
* Should the tick mark at the given value be bold, because it has
* interesting data associated with it?
*
* @param value A value along this view's x axis
*
* @return True if the tick label for the given value should be bold ( has
* relevant data), false otherwise
*/
abstract protected Boolean isTickBold(X value);
/**
* Apply this view's 'selection effect' to the given node, if applied is
* true. If applied is false, remove the affect
*
* @param node The node to apply the 'effect' to
* @param applied True if the effect should be applied, false if the effect
* should not
*/
abstract protected void applySelectionEffect(NodeType node, Boolean applied);
/**
* Get the label that should be used for a tick mark at the given value.
*
* @param tickValue The value to get a label for.
*
* @return a String to use for a tick mark label given a tick value.
*/
abstract protected String getTickMarkLabel(X tickValue);
/**
* Get 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
*/
abstract protected double getTickSpacing();
/**
* Get the X-Axis of this view's chart
*
* @return The horizontal axis used by this view's chart
*/
abstract protected Axis<X> getXAxis();
/**
* Get the Y-Axis of this view's chart
*
* @return The vertical axis used by this view's chart
*/
abstract protected Axis<Y> getYAxis();
/**
* Get the total amount of space (in pixels) the x-axis uses to pad the left
* and right sides. This value is used to keep decluttered axis aligned
* correctly.
*
* @return The x-axis margin (in pixels)
*/
abstract protected double getAxisMargin();
/**
* Make a series for each event type in a consistent order.
*/
protected final void createSeries() {
for (EventType eventType : EventType.allTypes) {
XYChart.Series<X, Y> series = new XYChart.Series<>();
series.setName(eventType.getDisplayName());
eventTypeToSeriesMap.put(eventType, series);
dataSeries.add(series);
}
}
/**
* Get the series for the given EventType.
*
* @param et The EventType to get the series for
*
* @return A Series object to contain all the events with the given
* EventType
*/
protected final XYChart.Series<X, Y> getSeries(final EventType et) {
return eventTypeToSeriesMap.get(et);
}
/**
* Constructor
*
* @param controller The TimelineController for this view.
*/
protected AbstractTimelineChart(TimeLineController controller) {
super(controller);
Platform.runLater(() -> {
VBox vBox = new VBox(getSpecificLabelPane(), getContextLabelPane());
vBox.setFillWidth(false);
HBox hBox = new HBox(getSpacer(), vBox);
hBox.setFillHeight(false);
setBottom(hBox);
DoubleBinding spacerSize = getYAxis().widthProperty().add(getYAxis().tickLengthProperty()).add(getAxisMargin());
getSpacer().minWidthProperty().bind(spacerSize);
getSpacer().prefWidthProperty().bind(spacerSize);
getSpacer().maxWidthProperty().bind(spacerSize);
});
createSeries();
selectedNodes.addListener((ListChangeListener.Change<? extends NodeType> change) -> {
while (change.next()) {
change.getRemoved().forEach(node -> applySelectionEffect(node, false));
change.getAddedSubList().forEach(node -> applySelectionEffect(node, true));
}
});
//show tooltip text in status bar
hoverProperty().addListener(hoverProp -> controller.setStatusMessage(isHover() ? getDefaultTooltip().getText() : ""));
}
/**
* Iterate through the list of tick-marks building a two level structure of
* replacement tick mark labels. (Visually) upper level has most
* detailed/highest frequency part of date/time (specific label). Second
* level has rest of date/time grouped by unchanging part (contextual
* label).
*
* eg:
*
* October-October-31_September-01_September-02_September-03
*
* becomes:
*
* _________30_________31___________01___________02___________03
*
* _________October___________|_____________September___________
*
*/
@ThreadConfined(type = ThreadConfined.ThreadType.JFX)
protected synchronized void layoutDateLabels() {
//clear old labels
contextLabelPane.getChildren().clear();
specificLabelPane.getChildren().clear();
//since the tickmarks aren't necessarily in value/position order,
//make a copy of the list sorted by position along axis
SortedList<Axis.TickMark<X>> tickMarks = getXAxis().getTickMarks().sorted(Comparator.comparing(Axis.TickMark::getPosition));
if (tickMarks.isEmpty()) {
/*
* Since StackedBarChart does some funky animation/background thread
* stuff, sometimes there are no tick marks even though there is
* data. Dispatching another call to layoutDateLables() allows that
* stuff time to run before we check a gain.
*/
Platform.runLater(this::layoutDateLabels);
} else {
//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 lastSeenContextLabel = dateTime.context;
//x-positions (pixels) of the current branch and leaf labels
double specificLabelX = 0;
if (dateTime.context.isEmpty()) {
//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) {
addSpecificLabel(new TwoPartDateTime(getTickMarkLabel(t.getValue())).specifics,
spacing,
specificLabelX,
isTickBold(t.getValue())
);
specificLabelX += spacing; //increment x
}
} else {
//there are two parts so ...
//initialize additional state
double contextLabelX = 0;
double contextLabelWidth = 0;
for (Axis.TickMark<X> t : tickMarks) {
//split the label into a TwoPartDateTime
dateTime = new TwoPartDateTime(getTickMarkLabel(t.getValue()));
//if we are still in the same context
if (lastSeenContextLabel.equals(dateTime.context)) {
//increment context width
contextLabelWidth += spacing;
} else {// we are on to a new context, so ...
addContextLabel(lastSeenContextLabel, contextLabelWidth, contextLabelX);
//and then update label, x-pos, and width
lastSeenContextLabel = dateTime.context;
contextLabelX += contextLabelWidth;
contextLabelWidth = spacing;
}
//add the specific label (highest frequency part)
addSpecificLabel(dateTime.specifics, spacing, specificLabelX, isTickBold(t.getValue()));
//increment specific position
specificLabelX += spacing;
}
//we have reached end so add label for current context
addContextLabel(lastSeenContextLabel, contextLabelWidth, contextLabelX);
}
}
//request layout since we have modified scene graph structure
requestParentLayout();
}
/**
* Add a Text Node to the specific label container for the decluttered axis
* labels.
*
* @param labelText The String to add.
* @param labelWidth The width, in pixels, of the space available for the
* text.
* @param labelX The horizontal position, in pixels, in the specificPane
* of the text.
* @param bold True if the text should be bold, false otherwise.
*/
private synchronized void addSpecificLabel(String labelText, double labelWidth, double labelX, boolean bold) {
Text label = new Text(" " + labelText + " "); //NON-NLS
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 (specificLabelPane.getChildren().isEmpty()) {
//just add first label
specificLabelPane.getChildren().add(label);
} else {
//otherwise don't actually add the label if it would intersect with previous label
final Node lastLabel = specificLabelPane.getChildren().get(specificLabelPane.getChildren().size() - 1);
if (false == lastLabel.getBoundsInParent().intersects(label.getBoundsInParent())) {
specificLabelPane.getChildren().add(label);
}
}
}
/**
* Add a Label Node to the contextual label container for the decluttered
* axis labels.
*
* @param labelText The String to add.
* @param labelWidth The width, in pixels, of the space to use for the label
* @param labelX The horizontal position, in pixels, in the specificPane
* of the text
*/
private synchronized void addContextLabel(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.setBorder(null);
} else { // subsequent labels have border on left to create dividers
label.setBorder(ONLY_LEFT_BORDER);
}
contextLabelPane.getChildren().add(label);
}
/**
* A simple data object used to represent a partial date as up to two parts.
* A low frequency part (context) containing all but the most specific
* element, and a highest frequency part containing the most specific
* element. If there is only one part, it will be in the context and the
* specifics 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 context;
/**
* The highest frequency part of a date/time eg 14 (2pm)
*/
private final String specifics;
/**
* Constructor
*
* @param dateString The Date/Time to represent, formatted as per
* RangeDivisionInfo.getTickFormatter().
*/
TwoPartDateTime(String dateString) {
//find index of separator to split on
int splitIndex = StringUtils.lastIndexOfAny(dateString, " ", "-", ":"); //NON-NLS
if (splitIndex < 0) { // there is only one part
specifics = dateString;
context = ""; //NON-NLS
} else { //split at index
specifics = StringUtils.substring(dateString, splitIndex + 1);
context = StringUtils.substring(dateString, 0, splitIndex);
}
}
}
}