/* * 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.countsview; import java.util.Arrays; import javafx.beans.Observable; import javafx.collections.ObservableList; import javafx.event.EventHandler; import javafx.scene.Cursor; import javafx.scene.Node; import javafx.scene.chart.CategoryAxis; import javafx.scene.chart.NumberAxis; import javafx.scene.chart.StackedBarChart; import javafx.scene.chart.XYChart; import javafx.scene.control.ContextMenu; import javafx.scene.control.SeparatorMenuItem; import javafx.scene.control.Tooltip; import javafx.scene.effect.DropShadow; import javafx.scene.effect.Effect; import javafx.scene.effect.Lighting; import javafx.scene.image.ImageView; import javafx.scene.input.MouseButton; import javafx.scene.input.MouseEvent; import javafx.util.StringConverter; import javax.swing.JOptionPane; import org.controlsfx.control.action.Action; import org.controlsfx.control.action.ActionUtils; import org.joda.time.DateTime; import org.joda.time.Interval; import org.joda.time.Seconds; import org.openide.util.NbBundle; import org.sleuthkit.autopsy.coreutils.ColorUtilities; import org.sleuthkit.autopsy.timeline.TimeLineController; import org.sleuthkit.autopsy.timeline.ViewMode; import org.sleuthkit.autopsy.timeline.datamodel.FilteredEventsModel; import org.sleuthkit.autopsy.timeline.datamodel.eventtype.EventType; import org.sleuthkit.autopsy.timeline.datamodel.eventtype.RootEventType; import org.sleuthkit.autopsy.timeline.ui.IntervalSelector; import org.sleuthkit.autopsy.timeline.ui.TimeLineChart; import org.sleuthkit.autopsy.timeline.utils.RangeDivisionInfo; /** * Customized StackedBarChart<String, Number> used to display the event counts * in CountsViewPane. */ final class EventCountsChart extends StackedBarChart<String, Number> implements TimeLineChart<String> { private static final Effect SELECTED_NODE_EFFECT = new Lighting(); private ContextMenu chartContextMenu; private final TimeLineController controller; private final FilteredEventsModel filteredEvents; private IntervalSelector<? extends String> intervalSelector; final ObservableList<Node> selectedNodes; /** * the RangeDivisionInfo for the currently displayed time range, used to * correct the interval provided by intervalSelector by padding the end with * one 'period' */ private RangeDivisionInfo rangeInfo; EventCountsChart(TimeLineController controller, CategoryAxis dateAxis, NumberAxis countAxis, ObservableList<Node> selectedNodes) { super(dateAxis, countAxis); this.controller = controller; this.filteredEvents = controller.getEventsModel(); //configure constant properties on axes and chart dateAxis.setAnimated(true); dateAxis.setLabel(null); dateAxis.setTickLabelsVisible(false); dateAxis.setTickLabelGap(0); countAxis.setAutoRanging(false); countAxis.setLowerBound(0); countAxis.setAnimated(true); countAxis.setMinorTickCount(0); countAxis.setTickLabelFormatter(new IntegerOnlyStringConverter()); setAlternativeRowFillVisible(true); setCategoryGap(2); setLegendVisible(false); setAnimated(true); setTitle(null); ChartDragHandler<String, EventCountsChart> chartDragHandler = new ChartDragHandler<>(this); setOnMousePressed(chartDragHandler); setOnMouseReleased(chartDragHandler); setOnMouseDragged(chartDragHandler); setOnMouseClicked(new MouseClickedHandler<>(this)); this.selectedNodes = selectedNodes; getController().getEventsModel().timeRangeProperty().addListener(o -> { clearIntervalSelector(); }); } @Override public void clearContextMenu() { chartContextMenu = null; } @Override public ContextMenu getContextMenu(MouseEvent clickEvent) { if (chartContextMenu != null) { chartContextMenu.hide(); } chartContextMenu = ActionUtils.createContextMenu( Arrays.asList(TimeLineChart.newZoomHistoyActionGroup(controller))); chartContextMenu.setAutoHide(true); return chartContextMenu; } @Override public TimeLineController getController() { return controller; } @Override public void clearIntervalSelector() { getChartChildren().remove(intervalSelector); intervalSelector = null; } @Override public IntervalSelector<? extends String> getIntervalSelector() { return intervalSelector; } @Override public void setIntervalSelector(IntervalSelector<? extends String> newIntervalSelector) { intervalSelector = newIntervalSelector; //Add a listener that sizes the interval selector to its preferred size. intervalSelector.prefHeightProperty().addListener(observable -> newIntervalSelector.autosize()); getChartChildren().add(getIntervalSelector()); } @Override public CountsIntervalSelector newIntervalSelector() { return new CountsIntervalSelector(this); } @Override public ObservableList<Node> getSelectedNodes() { return selectedNodes; } void setRangeInfo(RangeDivisionInfo rangeInfo) { this.rangeInfo = rangeInfo; } Effect getSelectionEffect() { return SELECTED_NODE_EFFECT; } /** * Add the bar click handler,tooltip, border styling and hover effect to the * node generated by StackedBarChart. * * @param series * @param itemIndex * @param item */ @NbBundle.Messages({ "# {0} - count", "# {1} - event type displayname", "# {2} - start date time", "# {3} - end date time", "CountsViewPane.tooltip.text={0} {1} events\nbetween {2}\nand {3}"}) @Override protected void dataItemAdded(Series<String, Number> series, int itemIndex, Data<String, Number> item) { ExtraData extraValue = (ExtraData) item.getExtraValue(); EventType eventType = extraValue.getEventType(); Interval interval = extraValue.getInterval(); long count = extraValue.getRawCount(); item.nodeProperty().addListener((Observable o) -> { final Node node = item.getNode(); if (node != null) { node.setStyle("-fx-border-width: 2; -fx-border-color: " + ColorUtilities.getRGBCode(eventType.getSuperType().getColor()) + "; -fx-bar-fill: " + ColorUtilities.getRGBCode(eventType.getColor())); // NON-NLS node.setCursor(Cursor.HAND); final Tooltip tooltip = new Tooltip(Bundle.CountsViewPane_tooltip_text( count, eventType.getDisplayName(), item.getXValue(), interval.getEnd().toString(rangeInfo.getTickFormatter()))); tooltip.setGraphic(new ImageView(eventType.getFXImage())); Tooltip.install(node, tooltip); node.setOnMouseEntered(mouseEntered -> node.setEffect(new DropShadow(10, eventType.getColor()))); node.setOnMouseExited(mouseExited -> node.setEffect(selectedNodes.contains(node) ? SELECTED_NODE_EFFECT : null)); node.setOnMouseClicked(new BarClickHandler(item)); } }); super.dataItemAdded(series, itemIndex, item); //To change body of generated methods, choose Tools | Templates. } /** * StringConvereter used to 'format' vertical axis labels */ private static class IntegerOnlyStringConverter extends StringConverter<Number> { @Override public String toString(Number n) { //suppress non-integer values return n.intValue() == n.doubleValue() ? Integer.toString(n.intValue()) : ""; } @Override public Number fromString(String string) { //this is unused but here for symmetry return Double.valueOf(string).intValue(); } } /** * Interval Selector for the counts chart, adjusts interval based on * rangeInfo to include final period */ final static private class CountsIntervalSelector extends IntervalSelector<String> { private final EventCountsChart countsChart; CountsIntervalSelector(EventCountsChart chart) { super(chart); this.countsChart = chart; } @Override protected String formatSpan(String date) { return date; } @Override protected Interval adjustInterval(Interval i) { //extend range to block bounderies (ie day, month, year) RangeDivisionInfo iInfo = RangeDivisionInfo.getRangeDivisionInfo(i); final long lowerBound = iInfo.getLowerBound(); final long upperBound = iInfo.getUpperBound(); final DateTime lowerDate = new DateTime(lowerBound, TimeLineController.getJodaTimeZone()); final DateTime upperDate = new DateTime(upperBound, TimeLineController.getJodaTimeZone()); //add extra block to end that gets cut of by conversion from string/category. return new Interval(lowerDate, upperDate.plus(countsChart.rangeInfo.getPeriodSize().getPeriod())); } @Override protected DateTime parseDateTime(String date) { return date == null ? new DateTime(countsChart.rangeInfo.getLowerBound()) : countsChart.rangeInfo.getTickFormatter().parseDateTime(date); } } /** * EventHandler for click events on nodes representing a bar(segment) in the * stacked bar chart. * * Concurrency Policy: This only accesses immutable state or javafx nodes * (from the jfx thread) and the internally synchronized * {@link TimeLineController} * * TODO: review for thread safety -jm */ private class BarClickHandler implements EventHandler<MouseEvent> { private ContextMenu barContextMenu; private final Interval interval; private final EventType type; private final Node node; private final String startDateString; BarClickHandler(XYChart.Data<String, Number> data) { EventCountsChart.ExtraData extraData = (EventCountsChart.ExtraData) data.getExtraValue(); this.interval = extraData.getInterval(); this.type = extraData.getEventType(); this.node = data.getNode(); this.startDateString = data.getXValue(); } @NbBundle.Messages({"Timeline.ui.countsview.menuItem.selectTimeRange=Select Time Range"}) class SelectIntervalAction extends Action { SelectIntervalAction() { super(Bundle.Timeline_ui_countsview_menuItem_selectTimeRange()); setEventHandler(action -> { controller.selectTimeAndType(interval, RootEventType.getInstance()); selectedNodes.clear(); for (XYChart.Series<String, Number> s : getData()) { s.getData().forEach((XYChart.Data<String, Number> d) -> { if (startDateString.contains(d.getXValue())) { selectedNodes.add(d.getNode()); } }); } }); } } @NbBundle.Messages({"Timeline.ui.countsview.menuItem.selectEventType=Select Event Type"}) class SelectTypeAction extends Action { SelectTypeAction() { super(Bundle.Timeline_ui_countsview_menuItem_selectEventType()); setEventHandler(action -> { controller.selectTimeAndType(filteredEvents.getSpanningInterval(), type); selectedNodes.clear(); getData().stream().filter(series -> series.getName().equals(type.getDisplayName())) .findFirst() .ifPresent(series -> series.getData().forEach(data -> selectedNodes.add(data.getNode()))); }); } } @NbBundle.Messages({"Timeline.ui.countsview.menuItem.selectTimeandType=Select Time and Type"}) class SelectIntervalAndTypeAction extends Action { SelectIntervalAndTypeAction() { super(Bundle.Timeline_ui_countsview_menuItem_selectTimeandType()); setEventHandler(action -> { controller.selectTimeAndType(interval, type); selectedNodes.setAll(node); }); } } @NbBundle.Messages({"Timeline.ui.countsview.menuItem.zoomIntoTimeRange=Zoom into Time Range"}) class ZoomToIntervalAction extends Action { ZoomToIntervalAction() { super(Bundle.Timeline_ui_countsview_menuItem_zoomIntoTimeRange()); setEventHandler(action -> { if (interval.toDuration().isShorterThan(Seconds.ONE.toStandardDuration()) == false) { controller.pushTimeRange(interval); } }); } } @Override @NbBundle.Messages({ "CountsViewPane.detailSwitchMessage=There is no temporal resolution smaller than Seconds.\nWould you like to switch to the Details view instead?", "CountsViewPane.detailSwitchTitle=\"Switch to Details View?"}) public void handle(final MouseEvent e) { e.consume(); if (e.getClickCount() == 1) { //single click => selection if (e.getButton().equals(MouseButton.PRIMARY)) { controller.selectTimeAndType(interval, type); selectedNodes.setAll(node); } else if (e.getButton().equals(MouseButton.SECONDARY)) { getContextMenu(e).hide(); if (barContextMenu == null) { barContextMenu = new ContextMenu(); barContextMenu.setAutoHide(true); barContextMenu.getItems().addAll( ActionUtils.createMenuItem(new SelectIntervalAction()), ActionUtils.createMenuItem(new SelectTypeAction()), ActionUtils.createMenuItem(new SelectIntervalAndTypeAction()), new SeparatorMenuItem(), ActionUtils.createMenuItem(new ZoomToIntervalAction())); barContextMenu.getItems().addAll(getContextMenu(e).getItems()); } barContextMenu.show(node, e.getScreenX(), e.getScreenY()); } } else if (e.getClickCount() >= 2) { //double-click => zoom in time if (interval.toDuration().isLongerThan(Seconds.ONE.toStandardDuration())) { controller.pushTimeRange(interval); } else { int showConfirmDialog = JOptionPane.showConfirmDialog(null, Bundle.CountsViewPane_detailSwitchMessage(), Bundle.CountsViewPane_detailSwitchTitle(), JOptionPane.YES_NO_OPTION); if (showConfirmDialog == JOptionPane.YES_OPTION) { controller.setViewMode(ViewMode.DETAIL); } } } } } /** * Encapsulate extra data stuffed into each {@link Data} item to give click * handler and tooltip access to more info. */ static class ExtraData { private final Interval interval; private final EventType eventType; private final long rawCount; ExtraData(Interval interval, EventType eventType, long rawCount) { this.interval = interval; this.eventType = eventType; this.rawCount = rawCount; } public long getRawCount() { return rawCount; } public Interval getInterval() { return interval; } public EventType getEventType() { return eventType; } } }