org.sleuthkit.autopsy.timeline.ui.AbstractTimelineChart.java Source code

Java tutorial

Introduction

Here is the source code for org.sleuthkit.autopsy.timeline.ui.AbstractTimelineChart.java

Source

/*
 * 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);
            }
        }
    }

}