diff --git a/Core/src/org/sleuthkit/autopsy/timeline/datamodel/EventBundle.java b/Core/src/org/sleuthkit/autopsy/timeline/datamodel/EventBundle.java
index 87144527254c0813e4f2eb1c7f3f2962a9fe563e..ba3333e7f7fe2b9cea4c38f72dca088b3dabd711 100644
--- a/Core/src/org/sleuthkit/autopsy/timeline/datamodel/EventBundle.java
+++ b/Core/src/org/sleuthkit/autopsy/timeline/datamodel/EventBundle.java
@@ -18,16 +18,16 @@
  */
 package org.sleuthkit.autopsy.timeline.datamodel;
 
-import com.google.common.collect.Range;
 import java.util.Optional;
 import java.util.Set;
+import java.util.SortedSet;
 import org.sleuthkit.autopsy.timeline.datamodel.eventtype.EventType;
 import org.sleuthkit.autopsy.timeline.zooming.DescriptionLoD;
 
 /**
- *
+ * A interface for groups of events that share some attributes in common.
  */
-public interface EventBundle {
+public interface EventBundle<ParentType extends EventBundle<?>> {
 
     String getDescription();
 
@@ -35,7 +35,6 @@ public interface EventBundle {
 
     Set<Long> getEventIDs();
 
-
     Set<Long> getEventIDsWithHashHits();
 
     Set<Long> getEventIDsWithTags();
@@ -46,11 +45,11 @@ public interface EventBundle {
 
     long getStartMillis();
 
-    Iterable<Range<Long>> getRanges();
+    Optional<ParentType> getParentBundle();
 
-    Optional<EventBundle> getParentBundle();
-
-    default  long getCount() {
+    default long getCount() {
         return getEventIDs().size();
     }
+
+    SortedSet<EventCluster> getClusters();
 }
diff --git a/Core/src/org/sleuthkit/autopsy/timeline/datamodel/EventCluster.java b/Core/src/org/sleuthkit/autopsy/timeline/datamodel/EventCluster.java
index 57d5cec0dbd0e775de9b770c95a54acc483f76de..592c4674f633d57721f7de7817eb012d91369266 100644
--- a/Core/src/org/sleuthkit/autopsy/timeline/datamodel/EventCluster.java
+++ b/Core/src/org/sleuthkit/autopsy/timeline/datamodel/EventCluster.java
@@ -18,12 +18,14 @@
  */
 package org.sleuthkit.autopsy.timeline.datamodel;
 
-import com.google.common.collect.Range;
+import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.collect.Sets;
 import java.util.Collections;
+import java.util.Comparator;
 import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
+import java.util.SortedSet;
 import javax.annotation.concurrent.Immutable;
 import org.joda.time.Interval;
 import org.sleuthkit.autopsy.timeline.datamodel.eventtype.EventType;
@@ -36,7 +38,7 @@
  * designated 'zoom level', and be 'close together' in time.
  */
 @Immutable
-public class EventCluster implements EventBundle {
+public class EventCluster implements EventBundle<EventStripe> {
 
     /**
      * merge two event clusters into one new event cluster.
@@ -62,7 +64,7 @@ public static EventCluster merge(EventCluster cluster1, EventCluster cluster2) {
         return new EventCluster(IntervalUtils.span(cluster1.span, cluster2.span), cluster1.getEventType(), idsUnion, hashHitsUnion, taggedUnion, cluster1.getDescription(), cluster1.lod);
     }
 
-    final private EventBundle parent;
+    final private EventStripe parent;
 
     /**
      * the smallest time interval containing all the clustered events
@@ -101,7 +103,7 @@ public static EventCluster merge(EventCluster cluster1, EventCluster cluster2) {
      */
     private final Set<Long> hashHits;
 
-    private EventCluster(Interval spanningInterval, EventType type, Set<Long> eventIDs, Set<Long> hashHits, Set<Long> tagged, String description, DescriptionLoD lod, EventBundle parent) {
+    private EventCluster(Interval spanningInterval, EventType type, Set<Long> eventIDs, Set<Long> hashHits, Set<Long> tagged, String description, DescriptionLoD lod, EventStripe parent) {
 
         this.span = spanningInterval;
         this.type = type;
@@ -118,7 +120,7 @@ public EventCluster(Interval spanningInterval, EventType type, Set<Long> eventID
     }
 
     @Override
-    public Optional<EventBundle> getParentBundle() {
+    public Optional<EventStripe> getParentBundle() {
         return Optional.ofNullable(parent);
     }
 
@@ -166,19 +168,6 @@ public DescriptionLoD getDescriptionLoD() {
         return lod;
     }
 
-    Range<Long> getRange() {
-        if (getEndMillis() > getStartMillis()) {
-            return Range.closedOpen(getSpan().getStartMillis(), getSpan().getEndMillis());
-        } else {
-            return Range.singleton(getStartMillis());
-        }
-    }
-
-    @Override
-    public Iterable<Range<Long>> getRanges() {
-        return Collections.singletonList(getRange());
-    }
-
     /**
      * return a new EventCluster identical to this one, except with the given
      * EventBundle as the parent.
@@ -188,11 +177,15 @@ public Iterable<Range<Long>> getRanges() {
      * @return a new EventCluster identical to this one, except with the given
      *         EventBundle as the parent.
      */
-    public EventCluster withParent(EventBundle parent) {
+    public EventCluster withParent(EventStripe parent) {
         if (Objects.nonNull(this.parent)) {
             throw new IllegalStateException("Event Cluster already has a parent!");
         }
         return new EventCluster(span, type, eventIDs, hashHits, tagged, description, lod, parent);
     }
 
+    @Override
+    public SortedSet< EventCluster> getClusters() {
+        return ImmutableSortedSet.orderedBy(Comparator.comparing(EventCluster::getStartMillis)).add(this).build();
+    }
 }
diff --git a/Core/src/org/sleuthkit/autopsy/timeline/datamodel/EventStripe.java b/Core/src/org/sleuthkit/autopsy/timeline/datamodel/EventStripe.java
index 8a465de1cd8577586724aebbd0328e88703b8ae8..cd457d336ece2c0ae0bb1a6466c24ab39e7ce4ea 100644
--- a/Core/src/org/sleuthkit/autopsy/timeline/datamodel/EventStripe.java
+++ b/Core/src/org/sleuthkit/autopsy/timeline/datamodel/EventStripe.java
@@ -1,20 +1,31 @@
 /*
- * To change this license header, choose License Headers in Project Properties.
- * To change this template file, choose Tools | Templates
- * and open the template in the editor.
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2015 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.datamodel;
 
 import com.google.common.base.Preconditions;
-import com.google.common.collect.Range;
-import com.google.common.collect.RangeMap;
-import com.google.common.collect.RangeSet;
-import com.google.common.collect.TreeRangeMap;
-import com.google.common.collect.TreeRangeSet;
 import java.util.Collections;
+import java.util.Comparator;
 import java.util.HashSet;
 import java.util.Optional;
 import java.util.Set;
+import java.util.SortedSet;
+import java.util.TreeSet;
 import javax.annotation.concurrent.Immutable;
 import org.python.google.common.base.Objects;
 import org.sleuthkit.autopsy.timeline.datamodel.eventtype.EventType;
@@ -22,10 +33,10 @@
 
 /**
  * A 'collection' of {@link EventCluster}s, all having the same type,
- * description, and zoom levels.
+ * description, and zoom levels, but not necessarily close together in time.
  */
 @Immutable
-public final class EventStripe implements EventBundle {
+public final class EventStripe implements EventBundle<EventCluster> {
 
     public static EventStripe merge(EventStripe u, EventStripe v) {
         Preconditions.checkNotNull(u);
@@ -37,10 +48,9 @@ public static EventStripe merge(EventStripe u, EventStripe v) {
         return new EventStripe(u, v);
     }
 
-    private final EventBundle parent;
+    private final EventCluster parent;
 
-    private final RangeSet<Long> spans = TreeRangeSet.create();
-    private final RangeMap<Long, EventCluster> spanMap = TreeRangeMap.create();
+    private final SortedSet<EventCluster> clusters = new TreeSet<>(Comparator.comparing(EventCluster::getStartMillis));
 
     /**
      * the type of all the events
@@ -73,23 +83,21 @@ public static EventStripe merge(EventStripe u, EventStripe v) {
      */
     private final Set<Long> hashHits = new HashSet<>();
 
-    public EventStripe(EventCluster cluster) {
-        spans.add(cluster.getRange());
-        spanMap.put(cluster.getRange(), cluster);
+    public EventStripe(EventCluster cluster, EventCluster parent) {
+        clusters.add(cluster);
+
         type = cluster.getEventType();
         description = cluster.getDescription();
         lod = cluster.getDescriptionLoD();
         eventIDs.addAll(cluster.getEventIDs());
         tagged.addAll(cluster.getEventIDsWithTags());
         hashHits.addAll(cluster.getEventIDsWithHashHits());
-        parent = cluster.getParentBundle().orElse(null);
+        this.parent = parent;
     }
 
     private EventStripe(EventStripe u, EventStripe v) {
-        spans.addAll(u.spans);
-        spans.addAll(v.spans);
-        spanMap.putAll(u.spanMap);
-        spanMap.putAll(v.spanMap);
+        clusters.addAll(u.clusters);
+        clusters.addAll(v.clusters);
         type = u.getEventType();
         description = u.getDescription();
         lod = u.getDescriptionLoD();
@@ -99,11 +107,11 @@ private EventStripe(EventStripe u, EventStripe v) {
         tagged.addAll(v.getEventIDsWithTags());
         hashHits.addAll(u.getEventIDsWithHashHits());
         hashHits.addAll(v.getEventIDsWithHashHits());
-        parent = u.getParentBundle().orElse(null);
+        parent = u.getParentBundle().orElse(v.getParentBundle().orElse(null));
     }
 
     @Override
-    public Optional<EventBundle> getParentBundle() {
+    public Optional<EventCluster> getParentBundle() {
         return Optional.ofNullable(parent);
     }
 
@@ -139,16 +147,15 @@ public Set<Long> getEventIDsWithTags() {
 
     @Override
     public long getStartMillis() {
-        return spans.span().lowerEndpoint();
+        return clusters.first().getStartMillis();
     }
 
     @Override
     public long getEndMillis() {
-        return spans.span().upperEndpoint();
+        return clusters.last().getEndMillis();
     }
 
-    @Override
-    public Iterable<Range<Long>> getRanges() {
-        return spans.asRanges();
+    public SortedSet< EventCluster> getClusters() {
+        return Collections.unmodifiableSortedSet(clusters);
     }
 }
diff --git a/Core/src/org/sleuthkit/autopsy/timeline/ui/detailview/DetailViewPane.java b/Core/src/org/sleuthkit/autopsy/timeline/ui/detailview/DetailViewPane.java
index 517cddde4e504a20c8db0ad9f89037167e819850..40ea7359380225e6578488c9627f887f9896518c 100644
--- a/Core/src/org/sleuthkit/autopsy/timeline/ui/detailview/DetailViewPane.java
+++ b/Core/src/org/sleuthkit/autopsy/timeline/ui/detailview/DetailViewPane.java
@@ -89,11 +89,12 @@
  * EventTypeMap, and dataSets is all linked directly to the ClusterChart which
  * must only be manipulated on the JavaFx thread.
  */
-public class DetailViewPane extends AbstractVisualization<DateTime, EventCluster, EventStripeNode, EventDetailChart> {
+public class DetailViewPane extends AbstractVisualization<DateTime, EventCluster, EventBundleNodeBase<?, ?, ?>, EventDetailChart> {
 
+    
     private final static Logger LOGGER = Logger.getLogger(DetailViewPane.class.getName());
 
-    private MultipleSelectionModel<TreeItem<EventBundle>> treeSelectionModel;
+    private MultipleSelectionModel<TreeItem<EventBundle<?>>> treeSelectionModel;
 
     //these three could be injected from fxml but it was causing npe's
     private final DateAxis dateAxis = new DateAxis();
@@ -107,9 +108,9 @@ public class DetailViewPane extends AbstractVisualization<DateTime, EventCluster
 
     private final Region region = new Region();
 
-    private final ObservableList<EventStripeNode> highlightedNodes = FXCollections.synchronizedObservableList(FXCollections.observableArrayList());
+    private final ObservableList<EventBundleNodeBase<?, ?, ?>> highlightedNodes = FXCollections.synchronizedObservableList(FXCollections.observableArrayList());
 
-    public ObservableList<EventBundle> getEventBundles() {
+    public ObservableList<EventBundle<?>> getEventBundles() {
         return chart.getEventBundles();
     }
 
@@ -136,7 +137,7 @@ public DetailViewPane(Pane partPane, Pane contextPane, Region spacer) {
         vertScrollBar.visibleAmountProperty().bind(chart.heightProperty().multiply(100).divide(chart.maxVScrollProperty()));
         requestLayout();
 
-        highlightedNodes.addListener((ListChangeListener.Change<? extends EventStripeNode> change) -> {
+        highlightedNodes.addListener((ListChangeListener.Change<? extends EventBundleNodeBase<?, ?, ?>> change) -> {
 
             while (change.next()) {
                 change.getAddedSubList().forEach(node -> {
@@ -201,7 +202,7 @@ public DetailViewPane(Pane partPane, Pane contextPane, Region spacer) {
             highlightedNodes.clear();
             selectedNodes.stream().forEach((tn) -> {
 
-                for (EventStripeNode n : chart.getNodes((EventStripeNode t) ->
+                for (EventBundleNodeBase<?, ?, ?> n : chart.getNodes((EventBundleNodeBase<?, ?, ?> t) ->
                         t.getDescription().equals(tn.getDescription()))) {
                     highlightedNodes.add(n);
                 }
@@ -219,14 +220,14 @@ private void incrementScrollValue(int factor) {
         vertScrollBar.valueProperty().set(Math.max(0, Math.min(100, vertScrollBar.getValue() + factor * (chart.getHeight() / chart.maxVScrollProperty().get()))));
     }
 
-    public void setSelectionModel(MultipleSelectionModel<TreeItem<EventBundle>> selectionModel) {
+    public void setSelectionModel(MultipleSelectionModel<TreeItem<EventBundle<?>>> selectionModel) {
         this.treeSelectionModel = selectionModel;
 
         treeSelectionModel.getSelectedItems().addListener((Observable observable) -> {
             highlightedNodes.clear();
-            for (TreeItem<EventBundle> tn : treeSelectionModel.getSelectedItems()) {
+            for (TreeItem<EventBundle<?>> tn : treeSelectionModel.getSelectedItems()) {
 
-                for (EventStripeNode n : chart.getNodes((EventStripeNode t) ->
+                for (EventBundleNodeBase<?, ?, ?> n : chart.getNodes((EventBundleNodeBase<?, ?, ?> t) ->
                         t.getDescription().equals(tn.getValue().getDescription()))) {
                     highlightedNodes.add(n);
                 }
@@ -351,7 +352,7 @@ protected Effect getSelectionEffect() {
     }
 
     @Override
-    protected void applySelectionEffect(EventStripeNode c1, Boolean selected) {
+    protected void applySelectionEffect(EventBundleNodeBase<?, ?, ?> c1, Boolean selected) {
         c1.applySelectionEffect(selected);
     }
 
@@ -475,4 +476,6 @@ public Action newUnhideDescriptionAction(String description, DescriptionLoD desc
     public Action newHideDescriptionAction(String description, DescriptionLoD descriptionLoD) {
         return chart.new HideDescriptionAction(description, descriptionLoD);
     }
+
+  
 }
diff --git a/Core/src/org/sleuthkit/autopsy/timeline/ui/detailview/EventBundleNodeBase.java b/Core/src/org/sleuthkit/autopsy/timeline/ui/detailview/EventBundleNodeBase.java
new file mode 100644
index 0000000000000000000000000000000000000000..ee57b6fe0d6be6feee0a179ca154772b31b93260
--- /dev/null
+++ b/Core/src/org/sleuthkit/autopsy/timeline/ui/detailview/EventBundleNodeBase.java
@@ -0,0 +1,337 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2015 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 java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutionException;
+import java.util.logging.Level;
+import java.util.stream.Collectors;
+import javafx.beans.Observable;
+import javafx.beans.property.SimpleObjectProperty;
+import javafx.beans.value.ObservableValue;
+import javafx.concurrent.Task;
+import javafx.geometry.Insets;
+import javafx.geometry.Pos;
+import javafx.scene.Node;
+import javafx.scene.control.Button;
+import javafx.scene.control.Label;
+import javafx.scene.control.Tooltip;
+import javafx.scene.effect.DropShadow;
+import javafx.scene.effect.Effect;
+import javafx.scene.image.Image;
+import javafx.scene.image.ImageView;
+import javafx.scene.input.MouseEvent;
+import javafx.scene.layout.Background;
+import javafx.scene.layout.BackgroundFill;
+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 static javafx.scene.layout.Region.USE_COMPUTED_SIZE;
+import static javafx.scene.layout.Region.USE_PREF_SIZE;
+import javafx.scene.layout.StackPane;
+import javafx.scene.paint.Color;
+import org.joda.time.DateTime;
+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.EventBundle;
+import org.sleuthkit.autopsy.timeline.datamodel.FilteredEventsModel;
+import org.sleuthkit.autopsy.timeline.datamodel.TimeLineEvent;
+import org.sleuthkit.autopsy.timeline.datamodel.eventtype.EventType;
+import static org.sleuthkit.autopsy.timeline.ui.detailview.EventBundleNodeBase.show;
+import org.sleuthkit.autopsy.timeline.zooming.DescriptionLoD;
+import org.sleuthkit.datamodel.SleuthkitCase;
+import org.sleuthkit.datamodel.TskCoreException;
+
+/**
+ *
+ */
+public abstract class EventBundleNodeBase<BundleType extends EventBundle<ParentType>, ParentType extends EventBundle<BundleType>, ParentNodeType extends EventBundleNodeBase<ParentType, BundleType, ?>> extends StackPane {
+
+    private static final Logger LOGGER = Logger.getLogger(EventBundleNodeBase.class.getName());
+    private static final Image HASH_PIN = new Image("/org/sleuthkit/autopsy/images/hashset_hits.png"); //NOI18N
+    private static final Image TAG = new Image("/org/sleuthkit/autopsy/images/green-tag-icon-16.png"); // NON-NLS //NOI18N
+
+    static final CornerRadii CORNER_RADII_3 = new CornerRadii(3);
+    static final CornerRadii CORNER_RADII_1 = new CornerRadii(1);
+
+    private final Border SELECTION_BORDER;
+    private static final Map<EventType, Effect> dropShadowMap = new ConcurrentHashMap<>();
+
+    static void configureLoDButton(Button b) {
+        b.setMinSize(16, 16);
+        b.setMaxSize(16, 16);
+        b.setPrefSize(16, 16);
+        show(b, false);
+    }
+
+    static void show(Node b, boolean show) {
+        b.setVisible(show);
+        b.setManaged(show);
+    }
+
+    protected final EventDetailChart chart;
+    final SimpleObjectProperty<DescriptionLoD> descLOD = new SimpleObjectProperty<>();
+    final SimpleObjectProperty<DescriptionVisibility> descVisibility = new SimpleObjectProperty<>(DescriptionVisibility.SHOWN);
+    protected final BundleType eventBundle;
+
+    protected final ParentNodeType parentNode;
+
+    final SleuthkitCase sleuthkitCase;
+    final FilteredEventsModel eventsModel;
+
+    final Background highlightedBackground;
+    final Background defaultBackground;
+    final Color evtColor;
+
+    final List<ParentNodeType> subNodes = new ArrayList<>();
+    final Pane subNodePane = new Pane();
+    final Label descrLabel = new Label();
+    final Label countLabel = new Label();
+
+    final ImageView hashIV = new ImageView(HASH_PIN);
+    final ImageView tagIV = new ImageView(TAG);
+    final HBox infoHBox = new HBox(5, descrLabel, countLabel, hashIV, tagIV);
+
+    private Tooltip tooltip;
+
+    public EventBundleNodeBase(EventDetailChart chart, BundleType eventBundle, ParentNodeType parentNode) {
+        this.eventBundle = eventBundle;
+        this.parentNode = parentNode;
+        this.chart = chart;
+
+        this.descLOD.set(eventBundle.getDescriptionLoD());
+        sleuthkitCase = chart.getController().getAutopsyCase().getSleuthkitCase();
+        eventsModel = chart.getController().getEventsModel();
+        evtColor = getEventType().getColor();
+        defaultBackground = new Background(new BackgroundFill(evtColor.deriveColor(0, 1, 1, .1), CORNER_RADII_3, Insets.EMPTY));
+        highlightedBackground = new Background(new BackgroundFill(evtColor.deriveColor(0, 1.1, 1.1, .3), CORNER_RADII_3, Insets.EMPTY));
+        SELECTION_BORDER = new Border(new BorderStroke(evtColor.darker().desaturate(), BorderStrokeStyle.SOLID, CORNER_RADII_3, new BorderWidths(2)));
+        if (eventBundle.getEventIDsWithHashHits().isEmpty()) {
+            show(hashIV, false);
+        }
+        if (eventBundle.getEventIDsWithTags().isEmpty()) {
+            show(tagIV, false);
+        }
+
+        setBackground(defaultBackground);
+        setAlignment(Pos.TOP_LEFT);
+
+        setPrefHeight(USE_COMPUTED_SIZE);
+        heightProperty().addListener((Observable observable) -> {
+            chart.layoutPlotChildren();
+        });
+        setMaxHeight(USE_PREF_SIZE);
+        setMaxWidth(USE_PREF_SIZE);
+        setLayoutX(chart.getXAxis().getDisplayPosition(new DateTime(eventBundle.getStartMillis())) - getLayoutXCompensation());
+
+        //initialize info hbox
+        infoHBox.setMinWidth(USE_PREF_SIZE);
+        infoHBox.setMaxWidth(USE_PREF_SIZE);
+        infoHBox.setPadding(new Insets(2, 5, 2, 5));
+        infoHBox.setAlignment(Pos.TOP_LEFT);
+        infoHBox.setPickOnBounds(true);
+
+        //set up subnode pane sizing contraints
+        subNodePane.setPrefHeight(USE_COMPUTED_SIZE);
+        subNodePane.setMaxHeight(USE_PREF_SIZE);
+        subNodePane.setPrefWidth(USE_COMPUTED_SIZE);
+        subNodePane.setMinWidth(USE_PREF_SIZE);
+        subNodePane.setMaxWidth(USE_PREF_SIZE);
+
+        //set up mouse hover effect and tooltip
+        setOnMouseEntered((MouseEvent e) -> {
+            /*
+             * defer tooltip creation till needed, this had a surprisingly large
+             * impact on speed of loading the chart
+             */
+            installTooltip();
+            showHoverControls(true);
+            toFront();
+        });
+        setOnMouseExited((MouseEvent event) -> {
+            showHoverControls(false);
+            if (parentNode != null) {
+                parentNode.showHoverControls(true);
+            }
+        });
+
+        setDescriptionVisibility(DescriptionVisibility.SHOWN);
+        descVisibility.addListener((ObservableValue<? extends DescriptionVisibility> observable, DescriptionVisibility oldValue, DescriptionVisibility newValue) -> {
+            setDescriptionVisibility(newValue);
+        });
+    }
+
+    final DescriptionLoD getDescriptionLoD() {
+        return descLOD.get();
+    }
+
+    public final BundleType getEventBundle() {
+        return eventBundle;
+    }
+
+    final double getLayoutXCompensation() {
+        return parentNode != null
+                ? chart.getXAxis().getDisplayPosition(new DateTime(parentNode.getStartMillis()))
+                : 0;
+    }
+
+    @NbBundle.Messages({"# {0} - counts",
+        "# {1} - event type",
+        "# {2} - description",
+        "# {3} - start date/time",
+        "# {4} - end date/time",
+        "EventBundleNodeBase.tooltip.text={0} {1} events\n{2}\nbetween\t{3}\nand   \t{4}"})
+    @ThreadConfined(type = ThreadConfined.ThreadType.JFX)
+    private void installTooltip() {
+        if (tooltip == null) {
+            final Task<String> tooltTipTask = new Task<String>() {
+
+                @Override
+                protected String call() throws Exception {
+                    HashMap<String, Long> hashSetCounts = new HashMap<>();
+                    if (eventBundle.getEventIDsWithHashHits().isEmpty() == false) {
+                        try {
+                            //TODO:push this to DB
+                            for (TimeLineEvent tle : eventsModel.getEventsById(eventBundle.getEventIDsWithHashHits())) {
+                                Set<String> hashSetNames = sleuthkitCase.getAbstractFileById(tle.getFileID()).getHashSetNames();
+                                for (String hashSetName : hashSetNames) {
+                                    hashSetCounts.merge(hashSetName, 1L, Long::sum);
+                                }
+                            }
+                        } catch (TskCoreException ex) {
+                            LOGGER.log(Level.SEVERE, "Error getting hashset hit info for event.", ex);
+                        }
+                    }
+                    String hashSetCountsString = hashSetCounts.entrySet().stream()
+                            .map((Map.Entry<String, Long> t) -> t.getKey() + " : " + t.getValue())
+                            .collect(Collectors.joining("\n"));
+
+                    Map<String, Long> tagCounts = new HashMap<>();
+                    if (eventBundle.getEventIDsWithTags().isEmpty() == false) {
+                        tagCounts.putAll(eventsModel.getTagCountsByTagName(eventBundle.getEventIDsWithTags()));
+                    }
+                    String tagCountsString = tagCounts.entrySet().stream()
+                            .map((Map.Entry<String, Long> t) -> t.getKey() + " : " + t.getValue())
+                            .collect(Collectors.joining("\n"));
+
+                    return Bundle.EventBundleNodeBase_tooltip_text(getEventIDs().size(), getEventType(), getDescription(),
+                            TimeLineController.getZonedFormatter().print(getStartMillis()),
+                            TimeLineController.getZonedFormatter().print(getEndMillis() + 1000))
+                            + (hashSetCountsString.isEmpty() ? "" : "\n\nHash Set Hits\n" + hashSetCountsString)
+                            + (tagCountsString.isEmpty() ? "" : "\n\nTags\n" + tagCountsString);
+                }
+
+                @Override
+                protected void succeeded() {
+                    super.succeeded();
+                    try {
+                        tooltip = new Tooltip(get());
+                        tooltip.setAutoHide(true);
+                        Tooltip.install(EventBundleNodeBase.this, tooltip);
+                    } catch (InterruptedException | ExecutionException ex) {
+                        LOGGER.log(Level.SEVERE, "Tooltip generation failed.", ex);
+                        Tooltip.uninstall(EventBundleNodeBase.this, tooltip);
+                        tooltip = null;
+                    }
+                }
+            };
+
+            chart.getController().monitorTask(tooltTipTask);
+        }
+    }
+
+    /**
+     * apply the 'effect' to visually indicate selection
+     *
+     * @param applied true to apply the selection 'effect', false to remove it
+     */
+    public void applySelectionEffect(boolean applied) {
+        setBorder(applied ? SELECTION_BORDER : null);
+    }
+
+    /**
+     * apply the 'effect' to visually indicate highlighted nodes
+     *
+     * @param applied true to apply the highlight 'effect', false to remove it
+     */
+    abstract void applyHighlightEffect(boolean applied);
+
+    @SuppressWarnings("unchecked")
+    public List<ParentNodeType> getSubNodes() {
+        return subNodes;
+    }
+
+    abstract void setDescriptionVisibility(DescriptionVisibility get);
+
+    void showHoverControls(final boolean showControls) {
+        Effect dropShadow = dropShadowMap.computeIfAbsent(getEventType(),
+                eventType -> new DropShadow(-10, eventType.getColor()));
+        setEffect(showControls ? dropShadow : null);
+        if (parentNode != null) {
+            parentNode.showHoverControls(false);
+        }
+    }
+
+    final EventType getEventType() {
+        return getEventBundle().getEventType();
+    }
+
+    final String getDescription() {
+        return getEventBundle().getDescription();
+    }
+
+    final long getStartMillis() {
+        return getEventBundle().getStartMillis();
+    }
+
+    final long getEndMillis() {
+        return getEventBundle().getEndMillis();
+    }
+
+    final Set<Long> getEventIDs() {
+        return getEventBundle().getEventIDs();
+    }
+
+    @Override
+    protected void layoutChildren() {
+        chart.layoutEventBundleNodes(subNodes, 0);
+        super.layoutChildren();
+    }
+
+    /**
+     * @param w the maximum width the description label should have
+     */
+    abstract void setDescriptionWidth(double w);
+
+    void setDescriptionVisibilityLevel(DescriptionVisibility get) {
+        descVisibility.set(get);
+    }
+
+}
diff --git a/Core/src/org/sleuthkit/autopsy/timeline/ui/detailview/EventClusterNode.java b/Core/src/org/sleuthkit/autopsy/timeline/ui/detailview/EventClusterNode.java
new file mode 100644
index 0000000000000000000000000000000000000000..f41be888ce800b9ba13d66d0d1ea44a2cb03af21
--- /dev/null
+++ b/Core/src/org/sleuthkit/autopsy/timeline/ui/detailview/EventClusterNode.java
@@ -0,0 +1,332 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013-15 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 java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
+import static java.util.Objects.nonNull;
+import java.util.concurrent.ExecutionException;
+import java.util.logging.Level;
+import java.util.stream.Collectors;
+import javafx.beans.binding.Bindings;
+import javafx.concurrent.Task;
+import javafx.event.ActionEvent;
+import javafx.event.EventHandler;
+import javafx.geometry.Pos;
+import javafx.scene.Cursor;
+import javafx.scene.control.Button;
+import javafx.scene.control.ContextMenu;
+import javafx.scene.control.SeparatorMenuItem;
+import javafx.scene.image.Image;
+import javafx.scene.image.ImageView;
+import javafx.scene.input.MouseButton;
+import javafx.scene.input.MouseEvent;
+import javafx.scene.layout.Border;
+import javafx.scene.layout.BorderStroke;
+import javafx.scene.layout.BorderStrokeStyle;
+import javafx.scene.layout.BorderWidths;
+import javafx.scene.layout.VBox;
+import org.controlsfx.control.action.Action;
+import org.controlsfx.control.action.ActionUtils;
+import org.joda.time.DateTime;
+import org.joda.time.Interval;
+import org.openide.util.NbBundle;
+import org.sleuthkit.autopsy.coreutils.Logger;
+import org.sleuthkit.autopsy.timeline.datamodel.EventCluster;
+import org.sleuthkit.autopsy.timeline.datamodel.EventStripe;
+import org.sleuthkit.autopsy.timeline.filters.DescriptionFilter;
+import org.sleuthkit.autopsy.timeline.filters.RootFilter;
+import org.sleuthkit.autopsy.timeline.filters.TypeFilter;
+import static org.sleuthkit.autopsy.timeline.ui.detailview.EventBundleNodeBase.configureLoDButton;
+import static org.sleuthkit.autopsy.timeline.ui.detailview.EventBundleNodeBase.show;
+import org.sleuthkit.autopsy.timeline.zooming.DescriptionLoD;
+import org.sleuthkit.autopsy.timeline.zooming.EventTypeZoomLevel;
+import org.sleuthkit.autopsy.timeline.zooming.ZoomParams;
+
+/**
+ *
+ */
+final public class EventClusterNode extends EventBundleNodeBase<EventCluster, EventStripe, EventStripeNode> {
+
+    private static final Logger LOGGER = Logger.getLogger(EventClusterNode.class.getName());
+    private static final BorderWidths CLUSTER_BORDER_WIDTHS = new BorderWidths(2, 1, 2, 1);
+    private static final Image PLUS = new Image("/org/sleuthkit/autopsy/timeline/images/plus-button.png"); // NON-NLS //NOI18N
+    private static final Image MINUS = new Image("/org/sleuthkit/autopsy/timeline/images/minus-button.png"); // NON-NLS //NOI18N
+    private final Border clusterBorder = new Border(new BorderStroke(evtColor.deriveColor(0, 1, 1, .4), BorderStrokeStyle.SOLID, CORNER_RADII_1, CLUSTER_BORDER_WIDTHS));
+
+    final Button plusButton = ActionUtils.createButton(new ExpandClusterAction(), ActionUtils.ActionTextBehavior.HIDE);
+    final Button minusButton = ActionUtils.createButton(new CollapseClusterAction(), ActionUtils.ActionTextBehavior.HIDE);
+
+    public EventClusterNode(EventDetailChart chart, EventCluster eventCluster, EventStripeNode parentNode) {
+        super(chart, eventCluster, parentNode);
+        setMinHeight(24);
+
+        subNodePane.setBorder(clusterBorder);
+        subNodePane.setBackground(defaultBackground);
+        subNodePane.setMaxHeight(USE_COMPUTED_SIZE);
+        subNodePane.setMaxWidth(USE_PREF_SIZE);
+        subNodePane.setMinWidth(1);
+
+        setCursor(Cursor.HAND);
+        setOnMouseClicked(new MouseClickHandler());
+
+        configureLoDButton(plusButton);
+        configureLoDButton(minusButton);
+
+        setAlignment(Pos.CENTER_LEFT);
+        infoHBox.getChildren().addAll(minusButton, plusButton);
+        getChildren().addAll(subNodePane, infoHBox);
+
+    }
+
+    @Override
+    void showHoverControls(final boolean showControls) {
+        super.showHoverControls(showControls);
+        show(plusButton, showControls);
+        show(minusButton, showControls);
+    }
+
+    @Override
+    void applyHighlightEffect(boolean applied) {
+//        throw new UnsupportedOperationException("Not supported yet."); //To change body of generated methods, choose Tools | Templates.
+    }
+
+    @Override
+    void setDescriptionWidth(double max) {
+//        throw new UnsupportedOperationException("Not supported yet."); //To change body of generated methods, choose Tools | Templates.
+    }
+
+    @Override
+    public void setDescriptionVisibility(DescriptionVisibility descrVis) {
+        final int size = getEventBundle().getEventIDs().size();
+        switch (descrVis) {
+            case HIDDEN:
+                countLabel.setText("");
+                descrLabel.setText("");
+                break;
+            case COUNT_ONLY:
+                descrLabel.setText("");
+                countLabel.setText(String.valueOf(size));
+                break;
+            default:
+            case SHOWN:
+                countLabel.setText(String.valueOf(size));
+                break;
+        }
+    }
+
+    /**
+     * loads sub-bundles at the given Description LOD, continues
+     *
+     * @param requestedDescrLoD
+     * @param expand
+     */
+    @NbBundle.Messages(value = "EventStripeNode.loggedTask.name=Load sub clusters")
+    private synchronized void loadSubBundles(DescriptionLoD.RelativeDetail relativeDetail) {
+        chart.setCursor(Cursor.WAIT);
+        chart.getEventBundles().removeIf(bundle ->
+                subNodes.stream().anyMatch(subNode ->
+                        bundle.equals(subNode.getEventStripe()))
+        );
+        subNodes.clear();
+
+        /*
+         * make new ZoomParams to query with
+         *
+         * We need to extend end time because for the query by one second,
+         * because it is treated as an open interval but we want to include
+         * events at exactly the time of the last event in this cluster
+         */
+        final RootFilter subClusterFilter = getSubClusterFilter();
+        final Interval subClusterSpan = new Interval(getStartMillis(), getEndMillis() + 1000);
+        final EventTypeZoomLevel eventTypeZoomLevel = eventsModel.eventTypeZoomProperty().get();
+        final ZoomParams zoomParams = new ZoomParams(subClusterSpan, eventTypeZoomLevel, subClusterFilter, getDescriptionLoD());
+
+        Task<Collection<EventStripe>> loggedTask = new Task<Collection<EventStripe>>() {
+
+            private volatile DescriptionLoD loadedDescriptionLoD = getDescriptionLoD().withRelativeDetail(relativeDetail);
+
+            {
+                updateTitle(Bundle.EventStripeNode_loggedTask_name());
+            }
+
+            @Override
+            protected Collection<EventStripe> call() throws Exception {
+                Collection<EventStripe> bundles;
+                DescriptionLoD next = loadedDescriptionLoD;
+                do {
+                    loadedDescriptionLoD = next;
+                    if (loadedDescriptionLoD == getEventBundle().getDescriptionLoD()) {
+                        return Collections.emptySet();
+                    }
+                    bundles = eventsModel.getEventClusters(zoomParams.withDescrLOD(loadedDescriptionLoD)).stream()
+                            .collect(Collectors.toMap(EventCluster::getDescription, //key
+                                            (eventCluster) -> new EventStripe(eventCluster, getEventCluster()), //value
+                                            EventStripe::merge) //merge method
+                            ).values();
+                    next = loadedDescriptionLoD.withRelativeDetail(relativeDetail);
+                } while (bundles.size() == 1 && nonNull(next));
+
+                // return list of AbstractEventStripeNodes representing sub-bundles
+                return bundles;
+
+            }
+
+            @Override
+            protected void succeeded() {
+
+                try {
+                    Collection<EventStripe> bundles = get();
+
+                    if (bundles.isEmpty()) {
+                        subNodePane.getChildren().clear();
+                        getChildren().setAll(subNodePane, infoHBox);
+                        descLOD.set(getEventBundle().getDescriptionLoD());
+                    } else {
+                        chart.getEventBundles().addAll(bundles);
+                        subNodes.addAll(bundles.stream()
+                                .map(EventClusterNode.this::createStripeNode)
+                                .sorted(Comparator.comparing(EventStripeNode::getStartMillis))
+                                .collect(Collectors.toList()));
+                        subNodePane.getChildren().setAll(subNodes);
+                        getChildren().setAll(new VBox(infoHBox, subNodePane));
+                        descLOD.set(loadedDescriptionLoD);
+                    }
+                } catch (InterruptedException | ExecutionException ex) {
+                    LOGGER.log(Level.SEVERE, "Error loading subnodes", ex);
+                }
+                chart.layoutPlotChildren();
+                chart.setCursor(null);
+            }
+        };
+
+        //start task
+        chart.getController().monitorTask(loggedTask);
+    }
+
+    private EventStripeNode createStripeNode(EventStripe stripe) {
+        return new EventStripeNode(chart, stripe, this);
+    }
+
+    EventCluster getEventCluster() {
+        return getEventBundle();
+    }
+
+    @Override
+    protected void layoutChildren() {
+        double chartX = chart.getXAxis().getDisplayPosition(new DateTime(getStartMillis()));
+        double w = chart.getXAxis().getDisplayPosition(new DateTime(getEndMillis())) - chartX;
+        subNodePane.setPrefWidth(w);
+        subNodePane.setMinWidth(Math.max(1, w));
+        super.layoutChildren();
+    }
+
+    /**
+     * make a new filter intersecting the global filter with description and
+     * type filters to restrict sub-clusters
+     *
+     */
+    RootFilter getSubClusterFilter() {
+        RootFilter subClusterFilter = eventsModel.filterProperty().get().copyOf();
+        subClusterFilter.getSubFilters().addAll(
+                new DescriptionFilter(getEventBundle().getDescriptionLoD(), getDescription(), DescriptionFilter.FilterMode.INCLUDE),
+                new TypeFilter(getEventType()));
+        return subClusterFilter;
+    }
+
+    /**
+     * event handler used for mouse events on {@link EventStripeNode}s
+     */
+    private class MouseClickHandler implements EventHandler<MouseEvent> {
+
+        private ContextMenu contextMenu;
+
+        @Override
+        public void handle(MouseEvent t) {
+
+            if (t.getButton() == MouseButton.PRIMARY) {
+
+                if (t.isShiftDown()) {
+                    if (chart.selectedNodes.contains(EventClusterNode.this) == false) {
+                        chart.selectedNodes.add(EventClusterNode.this);
+                    }
+                } else if (t.isShortcutDown()) {
+                    chart.selectedNodes.removeAll(EventClusterNode.this);
+                } else if (t.getClickCount() > 1) {
+                    final DescriptionLoD next = descLOD.get().moreDetailed();
+                    if (next != null) {
+                        loadSubBundles(DescriptionLoD.RelativeDetail.MORE);
+                    }
+                } else {
+                    chart.selectedNodes.setAll(EventClusterNode.this);
+                }
+                t.consume();
+            } else if (t.getButton() == MouseButton.SECONDARY) {
+                ContextMenu chartContextMenu = chart.getChartContextMenu(t);
+                if (contextMenu == null) {
+                    contextMenu = new ContextMenu();
+                    contextMenu.setAutoHide(true);
+
+                    contextMenu.getItems().add(ActionUtils.createMenuItem(new ExpandClusterAction()));
+                    contextMenu.getItems().add(ActionUtils.createMenuItem(new CollapseClusterAction()));
+
+                    contextMenu.getItems().add(new SeparatorMenuItem());
+                    contextMenu.getItems().addAll(chartContextMenu.getItems());
+                }
+                contextMenu.show(EventClusterNode.this, t.getScreenX(), t.getScreenY());
+                t.consume();
+            }
+        }
+    }
+
+    private class ExpandClusterAction extends Action {
+
+        @NbBundle.Messages(value = "ExpandClusterAction.text=Expand")
+        ExpandClusterAction() {
+            super(Bundle.ExpandClusterAction_text());
+
+            setGraphic(new ImageView(PLUS));
+            setEventHandler((ActionEvent t) -> {
+                final DescriptionLoD next = descLOD.get().moreDetailed();
+                if (next != null) {
+                    loadSubBundles(DescriptionLoD.RelativeDetail.MORE);
+                }
+            });
+            disabledProperty().bind(descLOD.isEqualTo(DescriptionLoD.FULL));
+        }
+    }
+
+    private class CollapseClusterAction extends Action {
+
+        @NbBundle.Messages(value = "CollapseClusterAction.text=Collapse")
+        CollapseClusterAction() {
+            super(Bundle.CollapseClusterAction_text());
+
+            setGraphic(new ImageView(MINUS));
+            setEventHandler((ActionEvent t) -> {
+                final DescriptionLoD previous = descLOD.get().lessDetailed();
+                if (previous != null) {
+                    loadSubBundles(DescriptionLoD.RelativeDetail.LESS);
+                }
+            });
+            disabledProperty().bind(Bindings.createBooleanBinding(() -> nonNull(getEventCluster()) && descLOD.get() == getEventCluster().getDescriptionLoD(), descLOD));
+        }
+    }
+}
diff --git a/Core/src/org/sleuthkit/autopsy/timeline/ui/detailview/EventDetailChart.java b/Core/src/org/sleuthkit/autopsy/timeline/ui/detailview/EventDetailChart.java
index da07263e9f3f9cd37fc7551e6f75d09e744a2204..1114cf6a72198facfa2c6c11945d2060b64bbf2c 100644
--- a/Core/src/org/sleuthkit/autopsy/timeline/ui/detailview/EventDetailChart.java
+++ b/Core/src/org/sleuthkit/autopsy/timeline/ui/detailview/EventDetailChart.java
@@ -19,13 +19,12 @@
 package org.sleuthkit.autopsy.timeline.ui.detailview;
 
 import com.google.common.collect.Range;
-import java.util.ArrayList;
+import com.google.common.collect.TreeRangeMap;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Comparator;
 import java.util.HashMap;
-import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
 import java.util.MissingResourceException;
@@ -38,7 +37,6 @@
 import javafx.animation.Timeline;
 import javafx.beans.InvalidationListener;
 import javafx.beans.Observable;
-import javafx.beans.property.Property;
 import javafx.beans.property.ReadOnlyDoubleProperty;
 import javafx.beans.property.ReadOnlyDoubleWrapper;
 import javafx.beans.property.SimpleBooleanProperty;
@@ -48,7 +46,6 @@
 import javafx.collections.ListChangeListener;
 import javafx.collections.ObservableList;
 import javafx.event.ActionEvent;
-import javafx.event.EventHandler;
 import javafx.geometry.Insets;
 import javafx.scene.Cursor;
 import javafx.scene.Group;
@@ -63,7 +60,6 @@
 import javafx.scene.shape.Line;
 import javafx.scene.shape.StrokeLineCap;
 import javafx.util.Duration;
-import javax.annotation.concurrent.GuardedBy;
 import org.apache.commons.lang3.tuple.ImmutablePair;
 import org.controlsfx.control.action.Action;
 import org.controlsfx.control.action.ActionGroup;
@@ -100,13 +96,12 @@
  */
 public final class EventDetailChart extends XYChart<DateTime, EventCluster> implements TimeLineChart<DateTime> {
 
-    static final Image HIDE = new Image("/org/sleuthkit/autopsy/timeline/images/eye--minus.png"); // NON-NLS
-    static final Image SHOW = new Image("/org/sleuthkit/autopsy/timeline/images/eye--plus.png"); // NON-NLS
+    private static final Image HIDE = new Image("/org/sleuthkit/autopsy/timeline/images/eye--minus.png"); // NON-NLS
+    private static final Image SHOW = new Image("/org/sleuthkit/autopsy/timeline/images/eye--plus.png"); // NON-NLS
     private static final Image MARKER = new Image("/org/sleuthkit/autopsy/timeline/images/marker.png", 16, 16, true, true, true);
     private static final int PROJECTED_LINE_Y_OFFSET = 5;
     private static final int PROJECTED_LINE_STROKE_WIDTH = 5;
-    private static final int DEFAULT_ROW_HEIGHT = 24;
-
+    private static final int MINIMUM_EVENT_NODE_GAP = 4;
     private ContextMenu chartContextMenu;
 
     private TimeLineController controller;
@@ -114,7 +109,7 @@ public final class EventDetailChart extends XYChart<DateTime, EventCluster> impl
     private FilteredEventsModel filteredEvents;
 
     /**
-     * a user position-able vertical line to help the compare events
+     * a user positionable vertical line to help compare events
      */
     private Line guideLine;
 
@@ -126,35 +121,27 @@ public final class EventDetailChart extends XYChart<DateTime, EventCluster> impl
     private IntervalSelector<? extends DateTime> intervalSelector;
 
     /**
-     * listener that triggers layout pass
+     * listener that triggers chart layout pass
      */
     private final InvalidationListener layoutInvalidationListener = (Observable o) -> {
-        synchronized (EventDetailChart.this) {
-            requiresLayout = true;
-            requestChartLayout();
-        }
+        layoutPlotChildren();
     };
 
     /**
      * the maximum y value used so far during the most recent layout pass
      */
     private final ReadOnlyDoubleWrapper maxY = new ReadOnlyDoubleWrapper(0.0);
-    /**
-     * flag indicating whether this chart actually needs a layout pass
-     */
-    @GuardedBy(value = "this")
-    private boolean requiresLayout = true;
 
-    final ObservableList<EventStripeNode> selectedNodes;
+    final ObservableList<EventBundleNodeBase<?, ?, ?>> selectedNodes;
     /**
      * the group that all event nodes are added to. This facilitates scrolling
      * by allowing a single translation of this group.
      */
     private final Group nodeGroup = new Group();
-    private final ObservableList<EventBundle> bundles = FXCollections.observableArrayList();
+    private final ObservableList<EventBundle<?>> bundles = FXCollections.observableArrayList();
     private final Map<ImmutablePair<EventType, String>, EventStripe> stripeDescMap = new HashMap<>();
     private final Map<EventStripe, EventStripeNode> stripeNodeMap = new HashMap<>();
-    private final Map<Range<Long>, Line> projectionMap = new HashMap<>();
+    private final Map<EventCluster, Line> projectionMap = new HashMap<>();
 
     /**
      * list of series of data added to this chart
@@ -164,11 +151,6 @@ public final class EventDetailChart extends XYChart<DateTime, EventCluster> impl
     private final ObservableList<Series<DateTime, EventCluster>> seriesList =
             FXCollections.<Series<DateTime, EventCluster>>observableArrayList();
 
-    private final ObservableList<Series<DateTime, EventCluster>> sortedSeriesList = seriesList
-            .sorted((s1, s2) -> {
-                final List<String> collect = EventType.allTypes.stream().map(EventType::getDisplayName).collect(Collectors.toList());
-                return Integer.compare(collect.indexOf(s1.getName()), collect.indexOf(s2.getName()));
-            });
     /**
      * true == layout each event type in its own band, false == mix all the
      * events together during layout
@@ -193,25 +175,23 @@ public final class EventDetailChart extends XYChart<DateTime, EventCluster> impl
      * the labels, alow them to extend past the timespan indicator and off the
      * edge of the screen
      */
-    private final SimpleBooleanProperty truncateAll = new SimpleBooleanProperty(false);
+    final SimpleBooleanProperty truncateAll = new SimpleBooleanProperty(false);
 
     /**
      * the width to truncate all labels to if truncateAll is true. adjustable
      * via slider if truncateAll is true
      */
-    private final SimpleDoubleProperty truncateWidth = new SimpleDoubleProperty(200.0);
-    private final SimpleBooleanProperty alternateLayout = new SimpleBooleanProperty(true);
+    final SimpleDoubleProperty truncateWidth = new SimpleDoubleProperty(200.0);
 
-    EventDetailChart(DateAxis dateAxis, final Axis<EventCluster> verticalAxis, ObservableList<EventStripeNode> selectedNodes) {
+    EventDetailChart(DateAxis dateAxis, final Axis<EventCluster> verticalAxis, ObservableList<EventBundleNodeBase<?, ?, ?>> selectedNodes) {
         super(dateAxis, verticalAxis);
         dateAxis.setAutoRanging(false);
 
         verticalAxis.setVisible(false);//TODO: why doesn't this hide the vertical axis, instead we have to turn off all parts individually? -jm
-
         verticalAxis.setTickLabelsVisible(false);
         verticalAxis.setTickMarkVisible(false);
-
         setLegendVisible(false);
+
         setPadding(Insets.EMPTY);
         setAlternativeColumnFillVisible(true);
 
@@ -219,8 +199,6 @@ public final class EventDetailChart extends XYChart<DateTime, EventCluster> impl
         getPlotChildren().add(nodeGroup);
 
         //add listener for events that should trigger layout
-        widthProperty().addListener(layoutInvalidationListener);
-        heightProperty().addListener(layoutInvalidationListener);
         bandByType.addListener(layoutInvalidationListener);
         oneEventPerRow.addListener(layoutInvalidationListener);
         truncateAll.addListener(layoutInvalidationListener);
@@ -233,8 +211,8 @@ public final class EventDetailChart extends XYChart<DateTime, EventCluster> impl
             setPrefHeight(boundsInLocalProperty().get().getHeight());
         });
 
-        //set up mouse listeners
-        final EventHandler<MouseEvent> clickHandler = (MouseEvent clickEvent) -> {
+        ///////set up mouse listeners
+        setOnMouseClicked((MouseEvent clickEvent) -> {
             if (chartContextMenu != null) {
                 chartContextMenu.hide();
             }
@@ -243,10 +221,7 @@ public final class EventDetailChart extends XYChart<DateTime, EventCluster> impl
                 chartContextMenu.show(EventDetailChart.this, clickEvent.getScreenX(), clickEvent.getScreenY());
                 clickEvent.consume();
             }
-        };
-
-        setOnMouseClicked(clickHandler);
-
+        });
         //use one handler with an if chain because it maintains state
         final ChartDragHandler<DateTime, EventDetailChart> dragHandler = new ChartDragHandler<>(this, getXAxis());
         setOnMousePressed(dragHandler);
@@ -254,42 +229,10 @@ public final class EventDetailChart extends XYChart<DateTime, EventCluster> impl
         setOnMouseDragged(dragHandler);
 
         this.selectedNodes = selectedNodes;
-        this.selectedNodes.addListener((
-                ListChangeListener.Change<? extends EventStripeNode> c) -> {
-                    while (c.next()) {
-                        c.getRemoved().forEach((EventStripeNode t) -> {
-                            t.getEventStripe().getRanges().forEach((Range<Long> t1) -> {
-
-                                Line removedLine = projectionMap.remove(t1);
-                                getChartChildren().removeAll(removedLine);
-                            });
-
-                        });
-                        c.getAddedSubList().forEach((EventStripeNode t) -> {
-
-                            for (Range<Long> range : t.getEventStripe().getRanges()) {
-
-                                Line line = new Line(dateAxis.localToParent(dateAxis.getDisplayPosition(new DateTime(range.lowerEndpoint(), TimeLineController.getJodaTimeZone())), 0).getX(), dateAxis.getLayoutY() + PROJECTED_LINE_Y_OFFSET,
-                                        dateAxis.localToParent(dateAxis.getDisplayPosition(new DateTime(range.upperEndpoint(), TimeLineController.getJodaTimeZone())), 0).getX(), dateAxis.getLayoutY() + PROJECTED_LINE_Y_OFFSET
-                                );
-                                line.setStroke(t.getEventType().getColor().deriveColor(0, 1, 1, .5));
-                                line.setStrokeWidth(PROJECTED_LINE_STROKE_WIDTH);
-                                line.setStrokeLineCap(StrokeLineCap.ROUND);
-                                projectionMap.put(range, line);
-                                getChartChildren().add(line);
-                            }
-                        });
-                    }
-
-                    this.controller.selectEventIDs(selectedNodes.stream()
-                            .flatMap(detailNode -> detailNode.getEventsIDs().stream())
-                            .collect(Collectors.toList()));
-                });
-
-        requestChartLayout();
+        this.selectedNodes.addListener(new SelectionChangeHandler());
     }
 
-    ObservableList<EventBundle> getEventBundles() {
+    ObservableList<EventBundle<?>> getEventBundles() {
         return bundles;
     }
 
@@ -400,7 +343,7 @@ protected synchronized void dataItemAdded(Series<DateTime, EventCluster> series,
         final EventCluster eventCluster = data.getYValue();
         bundles.add(eventCluster);
         EventStripe eventStripe = stripeDescMap.merge(ImmutablePair.of(eventCluster.getEventType(), eventCluster.getDescription()),
-                new EventStripe(eventCluster),
+                new EventStripe(eventCluster, null),
                 (EventStripe u, EventStripe v) -> {
                     EventStripeNode remove = stripeNodeMap.remove(u);
                     nodeGroup.getChildren().remove(remove);
@@ -413,7 +356,7 @@ protected synchronized void dataItemAdded(Series<DateTime, EventCluster> series,
         stripeNodeMap.put(eventStripe, stripeNode);
         nodeGroup.getChildren().add(stripeNode);
         data.setNode(stripeNode);
-
+        layoutPlotChildren();
     }
 
     @Override
@@ -429,103 +372,32 @@ protected synchronized void dataItemRemoved(Data<DateTime, EventCluster> data, S
         EventStripe removedStripe = stripeDescMap.remove(ImmutablePair.of(eventCluster.getEventType(), eventCluster.getDescription()));
         EventStripeNode removedNode = stripeNodeMap.remove(removedStripe);
         nodeGroup.getChildren().remove(removedNode);
-
         data.setNode(null);
-    }
-
-    synchronized void setRequiresLayout(boolean b) {
-        requiresLayout = true;
-    }
-
-    /**
-     * make this accessible to {@link EventStripeNode}
-     */
-    @Override
-    protected void requestChartLayout() {
-        super.requestChartLayout();
+        layoutPlotChildren();
     }
 
-    @Override
-    protected void layoutChildren() {
-        super.layoutChildren();
-
-    }
-
-    /**
-     * Layout the nodes representing events via the following algorithm.
-     *
-     * we start with a list of nodes (each representing an event) - sort the
-     * list of nodes by span start time of the underlying event - initialize
-     * empty map (maxXatY) from y-position to max used x-value - for each node:
-     * -- autosize the node (based on text label) -- get the event's start and
-     * end positions from the dateaxis -- size the capsule representing event
-     * duration -- starting from the top of the chart: --- (1)check if maxXatY
-     * is to the left of the start position: -------if maxXatY less than start
-     * position , good, put the current node here, mark end position as maxXatY,
-     * go to next node -------if maxXatY greater than start position, increment
-     * y position, do -------------check(1) again until maxXatY less than start
-     * position
-     */
     @Override
     protected synchronized void layoutPlotChildren() {
-        if (requiresLayout) {
-            setCursor(Cursor.WAIT);
-
-            maxY.set(0.0);
-
-            Map<Boolean, List<EventStripeNode>> hiddenPartition;
-            if (bandByType.get()) {
-                double minY = 0;
-                for (Series<DateTime, EventCluster> series : sortedSeriesList) {
-                    hiddenPartition = series.getData().stream().map(Data::getNode).map(EventStripeNode.class::cast)
-                            .collect(Collectors.partitioningBy(node -> getController().getQuickHideFilters().stream()
-                                            .filter(AbstractFilter::isActive)
-                                            .anyMatch(filter -> filter.getDescription().equals(node.getDescription()))));
-
-                    layoutNodesHelper(hiddenPartition.get(true), hiddenPartition.get(false), minY, 0);
-                    minY = maxY.get();
-                }
-            } else {
-                hiddenPartition = stripeNodeMap.values().stream()
-                        .collect(Collectors.partitioningBy(node -> getController().getQuickHideFilters().stream()
-                                        .filter(AbstractFilter::isActive)
-                                        .anyMatch(filter -> filter.getDescription().equals(node.getDescription()))));
-                layoutNodesHelper(hiddenPartition.get(true), hiddenPartition.get(false), 0, 0);
-            }
-            setCursor(null);
-            requiresLayout = false;
+        setCursor(Cursor.WAIT);
+        maxY.set(0);
+        if (bandByType.get()) {
+            stripeNodeMap.values().stream()
+                    .collect(Collectors.groupingBy(EventStripeNode::getEventType)).values()
+                    .forEach(inputNodes -> {
+                        List<EventStripeNode> stripeNodes = inputNodes.stream()
+                        .sorted(Comparator.comparing(EventStripeNode::getStartMillis))
+                        .collect(Collectors.toList());
+
+                        maxY.set(layoutEventBundleNodes(stripeNodes, maxY.get()));
+                    });
+        } else {
+            List<EventStripeNode> stripeNodes = stripeNodeMap.values().stream()
+                    .sorted(Comparator.comparing(EventStripeNode::getStartMillis))
+                    .collect(Collectors.toList());
+            maxY.set(layoutEventBundleNodes(stripeNodes, 0));
         }
         layoutProjectionMap();
-    }
-
-    /**
-     *
-     * @param hiddenNodes the value of hiddenNodes
-     * @param shownNodes  the value of shownNodes
-     * @param minY        the value of minY
-     * @param children    the value of children
-     * @param xOffset     the value of xOffset
-     *
-     * @return the double
-     */
-    private double layoutNodesHelper(List<EventStripeNode> hiddenNodes, List<EventStripeNode> shownNodes, double minY, final double xOffset) {
-
-        hiddenNodes.forEach((EventStripeNode t) -> {
-//            children.remove(t);
-            t.setVisible(false);
-            t.setManaged(false);
-        });
-
-        shownNodes.forEach((EventStripeNode t) -> {
-//            if (false == children.contains(t)) {
-//                children.add(t);
-//            }
-            t.setVisible(true);
-            t.setManaged(true);
-        });
-
-        shownNodes.sort(Comparator.comparing(EventStripeNode::getStartMillis));
-        return layoutNodes(shownNodes, minY, xOffset);
+        setCursor(null);
     }
 
     @Override
@@ -534,7 +406,6 @@ protected synchronized void seriesAdded(Series<DateTime, EventCluster> series, i
             dataItemAdded(series, j, series.getData().get(j));
         }
         seriesList.add(series);
-        requiresLayout = true;
     }
 
     @Override
@@ -543,18 +414,21 @@ protected synchronized void seriesRemoved(Series<DateTime, EventCluster> series)
             dataItemRemoved(series.getData().get(j), series);
         }
         seriesList.remove(series);
-        requiresLayout = true;
     }
 
     ReadOnlyDoubleProperty maxVScrollProperty() {
         return maxY.getReadOnlyProperty();
     }
 
-    Iterable<EventStripeNode> getNodes(Predicate<EventStripeNode> p) {
-        Function<EventStripeNode, Stream<EventStripeNode>> flattener =
-                new Function<EventStripeNode, Stream<EventStripeNode>>() {
+    /**
+     * @return all the nodes that pass the given predicate
+     */
+    Iterable<EventBundleNodeBase<?, ?, ?>> getNodes(Predicate<EventBundleNodeBase<?, ?, ?>> p) {
+        //use this recursive function to flatten the tree of nodes into an iterable.
+        Function<EventBundleNodeBase<?, ?, ?>, Stream<EventBundleNodeBase<?, ?, ?>>> stripeFlattener =
+                new Function<EventBundleNodeBase<?, ?, ?>, Stream<EventBundleNodeBase<?, ?, ?>>>() {
                     @Override
-                    public Stream<EventStripeNode> apply(EventStripeNode node) {
+                    public Stream<EventBundleNodeBase<?, ?, ?>> apply(EventBundleNodeBase<?, ?, ?> node) {
                         return Stream.concat(
                                 Stream.of(node),
                                 node.getSubNodes().stream().flatMap(this::apply));
@@ -562,11 +436,11 @@ public Stream<EventStripeNode> apply(EventStripeNode node) {
                 };
 
         return stripeNodeMap.values().stream()
-                .flatMap(flattener)
+                .flatMap(stripeFlattener)
                 .filter(p).collect(Collectors.toList());
     }
 
-    Iterable<EventStripeNode> getAllNodes() {
+    Iterable<EventBundleNodeBase<?, ?, ?>> getAllNodes() {
         return getNodes(x -> true);
     }
 
@@ -584,131 +458,114 @@ private void clearGuideLine() {
      * layout the nodes in the given list, starting form the given minimum y
      * coordinate.
      *
-     * @param nodes
-     * @param minY
+     * Layout the nodes representing events via the following algorithm.
+     *
+     * we start with a list of nodes (each representing an event) - sort the
+     * list of nodes by span start time of the underlying event - initialize
+     * empty map (maxXatY) from y-position to max used x-value - for each node:
+     *
+     * -- size the node based on its children (recursively)
+     *
+     * -- get the event's start position from the dateaxis
+     *
+     * -- to position node (1)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
+     * greater than start position, increment y position, do check(1) again
+     * until maxXatY less than start position
+     *
+     * @param nodes collection of nodes to layout
+     * @param minY  the minimum y coordinate to position the nodes at.
      */
-    private synchronized double layoutNodes(final Collection< EventStripeNode> nodes, final double minY, final double xOffset) {
-        //hash map from y value to right most occupied x value.  This tells you for a given 'row' what is the first avaialable slot
-        Map<Integer, Double> maxXatY = new HashMap<>();
+    synchronized double layoutEventBundleNodes(final Collection<? extends EventBundleNodeBase<?, ?, ?>> nodes, final double minY) {
+        // map from y value (ranges) to right most occupied x value.
+        TreeRangeMap<Double, Double> treeRangeMap = TreeRangeMap.create();
+        // maximum y values occupied by any of the given nodes,  updated as nodes are layed out.
         double localMax = minY;
-        //for each node lay size it and position it in first available slot
-
-        for (EventStripeNode stripeNode : nodes) {
-
-            stripeNode.setDescriptionVisibility(descrVisibility.get());
-            double rawDisplayPosition = getXAxis().getDisplayPosition(new DateTime(stripeNode.getStartMillis()));
-
-            //position of start and end according to range of axis
-            double startX = rawDisplayPosition - xOffset;
-            double layoutNodesResultHeight = 0;
-
-            double span = 0;
-
-            List<EventStripeNode> subNodes = stripeNode.getSubNodes();
-            if (subNodes.isEmpty() == false) {
-                Map<Boolean, List<EventStripeNode>> hiddenPartition = subNodes.stream()
-                        .collect(Collectors.partitioningBy(testNode -> getController().getQuickHideFilters().stream()
-                                        .filter(AbstractFilter::isActive)
-                                        .anyMatch(filter -> filter.getDescription().equals(testNode.getDescription()))));
-
-                layoutNodesResultHeight = layoutNodesHelper(hiddenPartition.get(true), hiddenPartition.get(false), minY, rawDisplayPosition);
-            }
-
-            List<Double> spanWidths = new ArrayList<>();
-            double x = getXAxis().getDisplayPosition(new DateTime(stripeNode.getStartMillis()));;
-            double x2;
-            Iterator<Range<Long>> ranges = stripeNode.getEventStripe().getRanges().iterator();
-            Range<Long> range = ranges.next();
-            do {
-                x2 = getXAxis().getDisplayPosition(new DateTime(range.upperEndpoint()));
-                double clusterSpan = x2 - x;
-                span += clusterSpan;
-                spanWidths.add(clusterSpan);
-                if (ranges.hasNext()) {
-                    range = ranges.next();
-                    x = getXAxis().getDisplayPosition(new DateTime(range.lowerEndpoint()));
-                    double gapSpan = x - x2;
-                    span += gapSpan;
-                    spanWidths.add(gapSpan);
-                    if (ranges.hasNext() == false) {
-                        x2 = getXAxis().getDisplayPosition(new DateTime(range.upperEndpoint()));
-                        clusterSpan = x2 - x;
-                        span += clusterSpan;
-                        spanWidths.add(clusterSpan);
-                    }
-                }
-
-            } while (ranges.hasNext());
-
-            stripeNode.setSpanWidths(spanWidths);
-
-            if (truncateAll.get()) { //if truncate option is selected limit width of description label
-                stripeNode.setDescriptionWidth(Math.max(span, truncateWidth.get()));
-            } else { //else set it unbounded
-                stripeNode.setDescriptionWidth(USE_PREF_SIZE);//20 + new Text(tlNode.getDisplayedDescription()).getLayoutBounds().getWidth());
-            }
-
-            stripeNode.autosize(); //compute size of tlNode based on constraints and event data
-
-            //get position of right edge of node ( influenced by description label)
-            double xRight = startX + stripeNode.getWidth();
-
-            //get the height of the node
-            final double h = layoutNodesResultHeight == 0 ? stripeNode.getHeight() : layoutNodesResultHeight + DEFAULT_ROW_HEIGHT;
-            //initial test position
-            double yPos = minY;
-
-            double yPos2 = yPos + h;
-
-            if (oneEventPerRow.get()) {
-                // if onePerRow, just put it at end
-                yPos = (localMax + 2);
-                yPos2 = yPos + h;
-
-            } else {//else
-
-                boolean overlapping = true;
-                while (overlapping) {
-                    //loop through y values looking for available slot.
 
-                    overlapping = false;
-                    //check each pixel from bottom to top.
-                    for (double y = yPos2; y >= yPos; y--) {
-                        final Double maxX = maxXatY.get((int) y);
-                        if (maxX != null && maxX >= startX - 4) {
-                            //if that pixel is already used
-                            //jump top to this y value and repeat until free slot is found.
-                            overlapping = true;
-                            yPos = y + 4;
-                            yPos2 = yPos + h;
-                            break;
+        //for each node do a recursive layout to size it and then position it in first available slot
+        for (final EventBundleNodeBase<?, ?, ?> bundleNode : nodes) {
+            //is the node hiden by a quick hide filter?
+            boolean quickHide = getController().getQuickHideFilters().stream()
+                    .filter(AbstractFilter::isActive)
+                    .anyMatch(filter -> filter.getDescription().equals(bundleNode.getDescription()));
+            if (quickHide) {
+                //hide it and skip layout
+                bundleNode.setVisible(false);
+                bundleNode.setManaged(false);
+            } else {
+                //make sure it is shown
+                bundleNode.setVisible(true);
+                bundleNode.setManaged(true);
+                //apply advanced layout description visibility options
+                bundleNode.setDescriptionVisibilityLevel(descrVisibility.get());
+                bundleNode.setDescriptionWidth(truncateAll.get() ? truncateWidth.get() : USE_PREF_SIZE);
+
+                //do recursive layout
+                bundleNode.layout();
+                //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;
+
+                //initial test position
+                double yTop = minY;
+                double yBottom = yTop + h;
+
+                if (oneEventPerRow.get()) {
+                    // if onePerRow, just put it at end
+                    yTop = (localMax + MINIMUM_EVENT_NODE_GAP);
+                    yBottom = yTop + h;
+                } else {
+                    //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--) {
+                            final Double maxX = treeRangeMap.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;
+                            }
                         }
                     }
+                    treeRangeMap.put(Range.closed(yTop, yBottom), xRight);
                 }
-                //mark used y values
-                for (double y = yPos; y <= yPos2; y++) {
-                    maxXatY.put((int) y, xRight);
-                }
-            }
-            localMax = Math.max(yPos2, localMax);
 
-            Timeline tm = new Timeline(new KeyFrame(Duration.seconds(1.0),
-                    new KeyValue(stripeNode.layoutXProperty(), startX),
-                    new KeyValue(stripeNode.layoutYProperty(), yPos)));
+                localMax = Math.max(yBottom, localMax);
 
-            tm.play();
+                //animate node to new position
+                Timeline timeline = new Timeline(new KeyFrame(Duration.millis(100),
+                        new KeyValue(bundleNode.layoutXProperty(), xLeft),
+                        new KeyValue(bundleNode.layoutYProperty(), yTop)));
+                timeline.setOnFinished((ActionEvent event) -> {
+                    requestChartLayout();
+                });
+                timeline.play();
+            }
         }
-        maxY.set(Math.max(maxY.get(), localMax));
-        return localMax - minY;
+        return localMax; //return new max
+    }
+
+    private double getXForEpochMillis(Long millis) {
+        DateTime dateTime = new DateTime(millis, TimeLineController.getJodaTimeZone());
+        return getXAxis().getDisplayPosition(new DateTime(dateTime));
     }
 
     private void layoutProjectionMap() {
-        for (final Map.Entry<Range<Long>, Line> entry : projectionMap.entrySet()) {
-            final Range<Long> range = entry.getKey();
+        for (final Map.Entry<EventCluster, Line> entry : projectionMap.entrySet()) {
+            final EventCluster cluster = entry.getKey();
             final Line line = entry.getValue();
 
-            line.setStartX(getParentXForEpochMillis(range.lowerEndpoint()));
-            line.setEndX(getParentXForEpochMillis(range.upperEndpoint()));
+            line.setStartX(getParentXForEpochMillis(cluster.getStartMillis()));
+            line.setEndX(getParentXForEpochMillis(cluster.getEndMillis()));
 
             line.setStartY(getXAxis().getLayoutY() + PROJECTED_LINE_Y_OFFSET);
             line.setEndY(getXAxis().getLayoutY() + PROJECTED_LINE_Y_OFFSET);
@@ -727,10 +584,6 @@ public FilteredEventsModel getFilteredEvents() {
         return filteredEvents;
     }
 
-    Property<Boolean> alternateLayoutProperty() {
-        return alternateLayout;
-    }
-
     static private class DetailIntervalSelector extends IntervalSelector<DateTime> {
 
         DetailIntervalSelector(double x, double height, Axis<DateTime> axis, TimeLineController controller) {
@@ -778,6 +631,45 @@ private class PlaceMarkerAction extends Action {
         }
     }
 
+    private class SelectionChangeHandler implements ListChangeListener<EventBundleNodeBase<?, ?, ?>> {
+
+        private final Axis<DateTime> dateAxis;
+
+        SelectionChangeHandler() {
+            dateAxis = getXAxis();
+        }
+
+        @Override
+        public void onChanged(ListChangeListener.Change<? extends EventBundleNodeBase<?, ?, ?>> change) {
+            while (change.next()) {
+                change.getRemoved().forEach((EventBundleNodeBase<?, ?, ?> removedNode) -> {
+                    removedNode.getEventBundle().getClusters().forEach(cluster -> {
+                        Line removedLine = projectionMap.remove(cluster);
+                        getChartChildren().removeAll(removedLine);
+                    });
+
+                });
+                change.getAddedSubList().forEach((EventBundleNodeBase<?, ?, ?> addedNode) -> {
+
+                    for (EventCluster range : addedNode.getEventBundle().getClusters()) {
+
+                        Line line = new Line(dateAxis.localToParent(dateAxis.getDisplayPosition(new DateTime(range.getStartMillis(), TimeLineController.getJodaTimeZone())), 0).getX(), dateAxis.getLayoutY() + PROJECTED_LINE_Y_OFFSET,
+                                dateAxis.localToParent(dateAxis.getDisplayPosition(new DateTime(range.getEndMillis(), TimeLineController.getJodaTimeZone())), 0).getX(), dateAxis.getLayoutY() + PROJECTED_LINE_Y_OFFSET
+                        );
+                        line.setStroke(addedNode.getEventType().getColor().deriveColor(0, 1, 1, .5));
+                        line.setStrokeWidth(PROJECTED_LINE_STROKE_WIDTH);
+                        line.setStrokeLineCap(StrokeLineCap.ROUND);
+                        projectionMap.put(range, line);
+                        getChartChildren().add(line);
+                    }
+                });
+            }
+            EventDetailChart.this.controller.selectEventIDs(selectedNodes.stream()
+                    .flatMap(detailNode -> detailNode.getEventIDs().stream())
+                    .collect(Collectors.toList()));
+        }
+    }
+
     class HideDescriptionAction extends Action {
 
         HideDescriptionAction(String description, DescriptionLoD descriptionLoD) {
@@ -792,12 +684,13 @@ class HideDescriptionAction extends Action {
                 DescriptionFilter descriptionFilter = getController().getQuickHideFilters().stream()
                         .filter(testFilter::equals)
                         .findFirst().orElseGet(() -> {
-                            testFilter.selectedProperty().addListener(layoutInvalidationListener);
+                            testFilter.selectedProperty().addListener((Observable observable) -> {
+                                layoutPlotChildren();
+                            });
                             getController().getQuickHideFilters().add(testFilter);
                             return testFilter;
                         });
                 descriptionFilter.setSelected(true);
-
             });
         }
     }
@@ -805,7 +698,6 @@ class HideDescriptionAction extends Action {
     class UnhideDescriptionAction extends Action {
 
         UnhideDescriptionAction(String description, DescriptionLoD descriptionLoD) {
-
             super("Unhide");
             setGraphic(new ImageView(SHOW));
             setEventHandler((ActionEvent t) ->
diff --git a/Core/src/org/sleuthkit/autopsy/timeline/ui/detailview/EventStripeNode.java b/Core/src/org/sleuthkit/autopsy/timeline/ui/detailview/EventStripeNode.java
index 666b0e01c8e175a64311eeaab3b78c45e66150b1..e840b278ded5c1b6377f48023cdc745ed1cea41f 100644
--- a/Core/src/org/sleuthkit/autopsy/timeline/ui/detailview/EventStripeNode.java
+++ b/Core/src/org/sleuthkit/autopsy/timeline/ui/detailview/EventStripeNode.java
@@ -19,107 +19,30 @@
  */
 package org.sleuthkit.autopsy.timeline.ui.detailview;
 
-import com.google.common.collect.Range;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import static java.util.Objects.nonNull;
-import java.util.Set;
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.ExecutionException;
-import java.util.logging.Level;
-import java.util.stream.Collectors;
-import javafx.beans.binding.Bindings;
-import javafx.beans.property.SimpleObjectProperty;
-import javafx.concurrent.Task;
-import javafx.event.ActionEvent;
 import javafx.event.EventHandler;
-import javafx.geometry.Insets;
 import javafx.geometry.Pos;
-import javafx.scene.Cursor;
-import javafx.scene.Node;
 import javafx.scene.control.Button;
 import javafx.scene.control.ContextMenu;
-import javafx.scene.control.Label;
+import javafx.scene.control.MenuItem;
 import javafx.scene.control.OverrunStyle;
-import javafx.scene.control.SeparatorMenuItem;
-import javafx.scene.control.Tooltip;
-import javafx.scene.effect.DropShadow;
-import javafx.scene.image.Image;
 import javafx.scene.image.ImageView;
 import javafx.scene.input.MouseButton;
 import javafx.scene.input.MouseEvent;
-import javafx.scene.layout.Background;
-import javafx.scene.layout.BackgroundFill;
-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 static javafx.scene.layout.Region.USE_PREF_SIZE;
-import javafx.scene.layout.StackPane;
 import javafx.scene.layout.VBox;
-import javafx.scene.paint.Color;
 import org.apache.commons.lang3.StringUtils;
-import org.controlsfx.control.action.Action;
 import org.controlsfx.control.action.ActionUtils;
-import org.joda.time.DateTime;
-import org.joda.time.Interval;
-import org.openide.util.NbBundle;
 import org.sleuthkit.autopsy.coreutils.Logger;
-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.FilteredEventsModel;
-import org.sleuthkit.autopsy.timeline.datamodel.TimeLineEvent;
-import org.sleuthkit.autopsy.timeline.datamodel.eventtype.EventType;
-import org.sleuthkit.autopsy.timeline.filters.DescriptionFilter;
-import org.sleuthkit.autopsy.timeline.filters.RootFilter;
-import org.sleuthkit.autopsy.timeline.filters.TypeFilter;
-import org.sleuthkit.autopsy.timeline.zooming.DescriptionLoD;
-import org.sleuthkit.autopsy.timeline.zooming.EventTypeZoomLevel;
-import org.sleuthkit.autopsy.timeline.zooming.ZoomParams;
-import org.sleuthkit.datamodel.SleuthkitCase;
-import org.sleuthkit.datamodel.TskCoreException;
+import static org.sleuthkit.autopsy.timeline.ui.detailview.EventBundleNodeBase.configureLoDButton;
 
 /**
  * Node used in {@link EventDetailChart} to represent an EventStripe.
  */
-final public class EventStripeNode extends StackPane {
+final public class EventStripeNode extends EventBundleNodeBase<EventStripe, EventCluster, EventClusterNode> {
 
     private static final Logger LOGGER = Logger.getLogger(EventStripeNode.class.getName());
-    private static final Image HASH_PIN = new Image("/org/sleuthkit/autopsy/images/hashset_hits.png"); //NOI18N
-    private static final Image PLUS = new Image("/org/sleuthkit/autopsy/timeline/images/plus-button.png"); // NON-NLS //NOI18N
-    private static final Image MINUS = new Image("/org/sleuthkit/autopsy/timeline/images/minus-button.png"); // NON-NLS //NOI18N
-    private static final Image TAG = new Image("/org/sleuthkit/autopsy/images/green-tag-icon-16.png"); // NON-NLS //NOI18N
-
-    private static final CornerRadii CORNER_RADII_3 = new CornerRadii(3);
-    private static final CornerRadii CORNER_RADII_1 = new CornerRadii(1);
-    private static final BorderWidths CLUSTER_BORDER_WIDTHS = new BorderWidths(2, 1, 2, 1);
-    private final static Map<EventType, DropShadow> dropShadowMap = new ConcurrentHashMap<>();
-    private static final Border SELECTION_BORDER = new Border(new BorderStroke(Color.BLACK, BorderStrokeStyle.SOLID, CORNER_RADII_3, new BorderWidths(2)));
-
-    static void configureLoDButton(Button b) {
-        b.setMinSize(16, 16);
-        b.setMaxSize(16, 16);
-        b.setPrefSize(16, 16);
-        show(b, false);
-    }
-
-    static void show(Node b, boolean show) {
-        b.setVisible(show);
-        b.setManaged(show);
-    }
-
-    private final SimpleObjectProperty<DescriptionLoD> descLOD = new SimpleObjectProperty<>();
-    private DescriptionVisibility descrVis;
-    private Tooltip tooltip;
-
+    final Button hideButton;
     /**
      * Pane that contains EventStripeNodes for any 'subevents' if they are
      * displayed
@@ -127,267 +50,60 @@ static void show(Node b, boolean show) {
      * //TODO: move more of the control of subnodes/events here and out of
      * EventDetail Chart
      */
-    private final Pane subNodePane = new Pane();
-    private final HBox clustersHBox = new HBox();
+//    private final HBox clustersHBox = new HBox();
     private final ImageView eventTypeImageView = new ImageView();
-    private final Label descrLabel = new Label("", eventTypeImageView);
-    private final Label countLabel = new Label();
-    private final Button plusButton = ActionUtils.createButton(new ExpandClusterAction(), ActionUtils.ActionTextBehavior.HIDE);
-    private final Button minusButton = ActionUtils.createButton(new CollapseClusterAction(), ActionUtils.ActionTextBehavior.HIDE);
-    private final ImageView hashIV = new ImageView(HASH_PIN);
-    private final ImageView tagIV = new ImageView(TAG);
-    private final HBox infoHBox = new HBox(5, descrLabel, countLabel, hashIV, tagIV, minusButton, plusButton);
 
-    private final Background highlightedBackground;
-    private final Background defaultBackground;
-    private final EventDetailChart chart;
-    private final SleuthkitCase sleuthkitCase;
-    private final EventStripe eventStripe;
-    private final EventStripeNode parentNode;
-    private final FilteredEventsModel eventsModel;
-    private final Button hideButton;
+    public EventStripeNode(EventDetailChart chart, EventStripe eventStripe, EventClusterNode parentNode) {
+        super(chart, eventStripe, parentNode);
 
-    public EventStripeNode(EventDetailChart chart, EventStripe eventStripe, EventStripeNode parentEventNode) {
-        this.eventStripe = eventStripe;
-        this.parentNode = parentEventNode;
-        this.chart = chart;
-        descLOD.set(eventStripe.getDescriptionLoD());
-        sleuthkitCase = chart.getController().getAutopsyCase().getSleuthkitCase();
-        eventsModel = chart.getController().getEventsModel();
-        final Color evtColor = getEventType().getColor();
-        defaultBackground = new Background(new BackgroundFill(evtColor.deriveColor(0, 1, 1, .1), CORNER_RADII_3, Insets.EMPTY));
-        highlightedBackground = new Background(new BackgroundFill(evtColor.deriveColor(0, 1.1, 1.1, .3), CORNER_RADII_3, Insets.EMPTY));
+        setMinHeight(48);
 
-        setBackground(defaultBackground);
-
-        setAlignment(Pos.TOP_LEFT);
-        setMinHeight(24);
-        setPrefHeight(USE_COMPUTED_SIZE);
-        setMaxHeight(USE_PREF_SIZE);
-        setMaxWidth(USE_PREF_SIZE);
-        minWidthProperty().bind(clustersHBox.widthProperty());
-        setLayoutX(chart.getXAxis().getDisplayPosition(new DateTime(eventStripe.getStartMillis())) - getLayoutXCompensation());
-
-        if (eventStripe.getEventIDsWithHashHits().isEmpty()) {
-            show(hashIV, false);
-        }
-        if (eventStripe.getEventIDsWithTags().isEmpty()) {
-            show(tagIV, false);
-        }
-
-        EventDetailChart.HideDescriptionAction hideClusterAction = chart.new HideDescriptionAction(getDescription(), eventStripe.getDescriptionLoD());
+        EventDetailChart.HideDescriptionAction hideClusterAction = chart.new HideDescriptionAction(getDescription(), eventBundle.getDescriptionLoD());
         hideButton = ActionUtils.createButton(hideClusterAction, ActionUtils.ActionTextBehavior.HIDE);
         configureLoDButton(hideButton);
-        configureLoDButton(plusButton);
-        configureLoDButton(minusButton);
-
-        //initialize info hbox
-        infoHBox.getChildren().add(4, hideButton);
 
-        infoHBox.setMinWidth(USE_PREF_SIZE);
-        infoHBox.setPadding(new Insets(2, 5, 2, 5));
-        infoHBox.setAlignment(Pos.CENTER_LEFT);
+        infoHBox.getChildren().add(hideButton);
         //setup description label
-
         eventTypeImageView.setImage(getEventType().getFXImage());
         descrLabel.setPrefWidth(USE_COMPUTED_SIZE);
         descrLabel.setTextOverrun(OverrunStyle.CENTER_ELLIPSIS);
-        descrLabel.setMouseTransparent(true);
-
-        //set up subnode pane sizing contraints
-        subNodePane.setPrefHeight(USE_COMPUTED_SIZE);
-        subNodePane.setMinHeight(USE_PREF_SIZE);
-        subNodePane.setMinWidth(USE_PREF_SIZE);
-        subNodePane.setMaxHeight(USE_PREF_SIZE);
-        subNodePane.setMaxWidth(USE_PREF_SIZE);
-        subNodePane.setPickOnBounds(false);
+        descrLabel.setGraphic(eventTypeImageView);
 
-        Border clusterBorder = new Border(new BorderStroke(evtColor.deriveColor(0, 1, 1, .4), BorderStrokeStyle.SOLID, CORNER_RADII_1, CLUSTER_BORDER_WIDTHS));
-        for (Range<Long> range : eventStripe.getRanges()) {
-            Region clusterRegion = new Region();
-            clusterRegion.setBorder(clusterBorder);
-            clusterRegion.setBackground(highlightedBackground);
-            clustersHBox.getChildren().addAll(clusterRegion, new Region());
+        setAlignment(subNodePane, Pos.BOTTOM_LEFT);
+        for (EventCluster cluster : eventStripe.getClusters()) {
+            EventClusterNode clusterNode = new EventClusterNode(chart, cluster, this);
+            subNodes.add(clusterNode);
+            subNodePane.getChildren().addAll(clusterNode);
         }
-        clustersHBox.getChildren().remove(clustersHBox.getChildren().size() - 1);
-        clustersHBox.setMaxWidth(USE_PREF_SIZE);
 
-        final VBox internalVBox = new VBox(infoHBox, subNodePane);
-        internalVBox.setAlignment(Pos.CENTER_LEFT);
-        getChildren().addAll(clustersHBox, internalVBox);
-
-        setCursor(Cursor.HAND);
+        getChildren().addAll(new VBox(infoHBox, subNodePane));
         setOnMouseClicked(new MouseClickHandler());
-
-        //set up mouse hover effect and tooltip
-        setOnMouseEntered((MouseEvent e) -> {
-            /*
-             * defer tooltip creation till needed, this had a surprisingly large
-             * impact on speed of loading the chart
-             */
-            installTooltip();
-            showDescriptionLoDControls(true);
-            toFront();
-        });
-
-        setOnMouseExited((MouseEvent e) -> {
-            showDescriptionLoDControls(false);
-        });
     }
 
-    void showDescriptionLoDControls(final boolean showControls) {
-        DropShadow dropShadow = dropShadowMap.computeIfAbsent(getEventType(),
-                eventType -> new DropShadow(10, eventType.getColor()));
-        clustersHBox.setEffect(showControls ? dropShadow : null);
-        show(minusButton, showControls);
-        show(plusButton, showControls);
+    @Override
+    void showHoverControls(final boolean showControls) {
+        super.showHoverControls(showControls);
         show(hideButton, showControls);
     }
 
-    public void setSpanWidths(List<Double> spanWidths) {
-        for (int i = 0; i < spanWidths.size(); i++) {
-            Region spanRegion = (Region) clustersHBox.getChildren().get(i);
-
-            Double w = spanWidths.get(i);
-            spanRegion.setPrefWidth(w);
-            spanRegion.setMaxWidth(w);
-            spanRegion.setMinWidth(Math.max(2, w));
-        }
-    }
-
     public EventStripe getEventStripe() {
-        return eventStripe;
-    }
-
-    Collection<EventStripe> makeBundlesFromClusters(List<EventCluster> eventClusters) {
-        return eventClusters.stream().collect(
-                Collectors.toMap(
-                        EventCluster::getDescription, //key
-                        EventStripe::new, //value
-                        EventStripe::merge)//merge method
-        ).values();
-    }
-
-    @NbBundle.Messages({"# {0} - counts",
-        "# {1} - event type",
-        "# {2} - description",
-        "# {3} - start date/time",
-        "# {4} - end date/time",
-        "EventStripeNode.tooltip.text={0} {1} events\n{2}\nbetween\t{3}\nand   \t{4}"})
-    synchronized void installTooltip() {
-        if (tooltip == null) {
-            final Task<String> tooltTipTask = new Task<String>() {
-
-                @Override
-                protected String call() throws Exception {
-                    HashMap<String, Long> hashSetCounts = new HashMap<>();
-                    if (!eventStripe.getEventIDsWithHashHits().isEmpty()) {
-                        hashSetCounts = new HashMap<>();
-                        try {
-                            for (TimeLineEvent tle : eventsModel.getEventsById(eventStripe.getEventIDsWithHashHits())) {
-                                Set<String> hashSetNames = sleuthkitCase.getAbstractFileById(tle.getFileID()).getHashSetNames();
-                                for (String hashSetName : hashSetNames) {
-                                    hashSetCounts.merge(hashSetName, 1L, Long::sum);
-                                }
-                            }
-                        } catch (TskCoreException ex) {
-                            LOGGER.log(Level.SEVERE, "Error getting hashset hit info for event.", ex);
-                        }
-                    }
-
-                    Map<String, Long> tagCounts = new HashMap<>();
-                    if (getEventStripe().getEventIDsWithTags().isEmpty() == false) {
-                        tagCounts.putAll(eventsModel.getTagCountsByTagName(getEventStripe().getEventIDsWithTags()));
-                    }
-
-                    String hashSetCountsString = hashSetCounts.entrySet().stream()
-                            .map((Map.Entry<String, Long> t) -> t.getKey() + " : " + t.getValue())
-                            .collect(Collectors.joining("\n"));
-                    String tagCountsString = tagCounts.entrySet().stream()
-                            .map((Map.Entry<String, Long> t) -> t.getKey() + " : " + t.getValue())
-                            .collect(Collectors.joining("\n"));
-                    return Bundle.EventStripeNode_tooltip_text(getEventStripe().getEventIDs().size(), getEventStripe().getEventType(), getEventStripe().getDescription(),
-                            TimeLineController.getZonedFormatter().print(getEventStripe().getStartMillis()),
-                            TimeLineController.getZonedFormatter().print(getEventStripe().getEndMillis() + 1000))
-                            + (hashSetCountsString.isEmpty() ? "" : "\n\nHash Set Hits\n" + hashSetCountsString)
-                            + (tagCountsString.isEmpty() ? "" : "\n\nTags\n" + tagCountsString);
-                }
-
-                @Override
-                protected void succeeded() {
-                    super.succeeded();
-                    try {
-                        tooltip = new Tooltip(get());
-                        Tooltip.install(EventStripeNode.this, tooltip);
-                    } catch (InterruptedException | ExecutionException ex) {
-                        LOGGER.log(Level.SEVERE, "Tooltip generation failed.", ex);
-                        Tooltip.uninstall(EventStripeNode.this, tooltip);
-                        tooltip = null;
-                    }
-                }
-            };
-
-            chart.getController().monitorTask(tooltTipTask);
-        }
-    }
-
-    EventStripeNode getNodeForBundle(EventStripe cluster) {
-        return new EventStripeNode(chart, cluster, this);
-    }
-
-    EventType getEventType() {
-        return eventStripe.getEventType();
-    }
-
-    String getDescription() {
-        return eventStripe.getDescription();
-    }
-
-    long getStartMillis() {
-        return eventStripe.getStartMillis();
-    }
-
-    @SuppressWarnings("unchecked")
-    public List<EventStripeNode> getSubNodes() {
-        return subNodePane.getChildrenUnmodifiable().stream()
-                .map(t -> (EventStripeNode) t)
-                .collect(Collectors.toList());
-    }
-
-    /**
-     * make a new filter intersecting the global filter with description and
-     * type filters to restrict sub-clusters
-     *
-     */
-    RootFilter getSubClusterFilter() {
-        RootFilter subClusterFilter = eventsModel.filterProperty().get().copyOf();
-        subClusterFilter.getSubFilters().addAll(
-                new DescriptionFilter(eventStripe.getDescriptionLoD(), eventStripe.getDescription(), DescriptionFilter.FilterMode.INCLUDE),
-                new TypeFilter(getEventType()));
-        return subClusterFilter;
+        return getEventBundle();
     }
 
     /**
      * @param w the maximum width the description label should have
      */
+    @Override
     public void setDescriptionWidth(double w) {
         descrLabel.setMaxWidth(w);
     }
 
-    /**
-     * apply the 'effect' to visually indicate selection
-     *
-     * @param applied true to apply the selection 'effect', false to remove it
-     */
-    public void applySelectionEffect(boolean applied) {
-        setBorder(applied ? SELECTION_BORDER : null);
-    }
-
     /**
      * apply the 'effect' to visually indicate highlighted nodes
      *
      * @param applied true to apply the highlight 'effect', false to remove it
      */
+    @Override
     public synchronized void applyHighlightEffect(boolean applied) {
         if (applied) {
             descrLabel.setStyle("-fx-font-weight: bold;"); // NON-NLS
@@ -398,116 +114,11 @@ public synchronized void applyHighlightEffect(boolean applied) {
         }
     }
 
-    private DescriptionLoD getDescriptionLoD() {
-        return descLOD.get();
-    }
-
-    /**
-     * loads sub-bundles at the given Description LOD, continues
-     *
-     * @param requestedDescrLoD
-     * @param expand
-     */
-    @NbBundle.Messages(value = "EventStripeNode.loggedTask.name=Load sub clusters")
-    private synchronized void loadSubBundles(DescriptionLoD.RelativeDetail relativeDetail) {
-        chart.getEventBundles().removeIf(bundle ->
-                getSubNodes().stream().anyMatch(subNode ->
-                        bundle.equals(subNode.getEventStripe()))
-        );
-        subNodePane.getChildren().clear();
-        if (descLOD.get().withRelativeDetail(relativeDetail) == eventStripe.getDescriptionLoD()) {
-            descLOD.set(eventStripe.getDescriptionLoD());
-            clustersHBox.setVisible(true);
-            chart.setRequiresLayout(true);
-            chart.requestChartLayout();
-        } else {
-            clustersHBox.setVisible(false);
-
-            // make new ZoomParams to query with
-            final RootFilter subClusterFilter = getSubClusterFilter();
-            /*
-             * We need to extend end time because for the query by one second,
-             * because it is treated as an open interval but we want to include
-             * events at exactly the time of the last event in this cluster
-             */
-            final Interval subClusterSpan = new Interval(eventStripe.getStartMillis(), eventStripe.getEndMillis() + 1000);
-            final EventTypeZoomLevel eventTypeZoomLevel = eventsModel.eventTypeZoomProperty().get();
-            final ZoomParams zoomParams = new ZoomParams(subClusterSpan, eventTypeZoomLevel, subClusterFilter, getDescriptionLoD());
-
-            Task<Collection<EventStripe>> loggedTask = new Task<Collection<EventStripe>>() {
-
-                private volatile DescriptionLoD loadedDescriptionLoD = getDescriptionLoD().withRelativeDetail(relativeDetail);
-
-                {
-                    updateTitle(Bundle.EventStripeNode_loggedTask_name());
-                }
-
-                @Override
-                protected Collection<EventStripe> call() throws Exception {
-                    Collection<EventStripe> bundles;
-                    DescriptionLoD next = loadedDescriptionLoD;
-                    do {
-                        loadedDescriptionLoD = next;
-                        if (loadedDescriptionLoD == eventStripe.getDescriptionLoD()) {
-                            return Collections.emptySet();
-                        }
-                        bundles = eventsModel.getEventClusters(zoomParams.withDescrLOD(loadedDescriptionLoD)).stream()
-                                .map(cluster -> cluster.withParent(getEventStripe()))
-                                .collect(Collectors.toMap(
-                                                EventCluster::getDescription, //key
-                                                EventStripe::new, //value
-                                                EventStripe::merge) //merge method
-                                ).values();
-                        next = loadedDescriptionLoD.withRelativeDetail(relativeDetail);
-                    } while (bundles.size() == 1 && nonNull(next));
-
-                    // return list of AbstractEventStripeNodes representing sub-bundles
-                    return bundles;
-
-                }
-
-                @Override
-                protected void succeeded() {
-                    chart.setCursor(Cursor.WAIT);
-                    try {
-                        Collection<EventStripe> bundles = get();
-
-                        if (bundles.isEmpty()) {
-                            clustersHBox.setVisible(true);
-                        } else {
-                            clustersHBox.setVisible(false);
-                            chart.getEventBundles().addAll(bundles);
-                            subNodePane.getChildren().setAll(bundles.stream()
-                                    .map(EventStripeNode.this::getNodeForBundle)
-                                    .collect(Collectors.toSet()));
-                        }
-                        descLOD.set(loadedDescriptionLoD);
-                        //assign subNodes and request chart layout
-
-                        chart.setRequiresLayout(true);
-                        chart.requestChartLayout();
-                    } catch (InterruptedException | ExecutionException ex) {
-                        LOGGER.log(Level.SEVERE, "Error loading subnodes", ex);
-                    }
-                    chart.setCursor(null);
-                }
-            };
-
-//start task
-            chart.getController().monitorTask(loggedTask);
-        }
-    }
-
-    private double getLayoutXCompensation() {
-        return (parentNode != null ? parentNode.getLayoutXCompensation() : 0)
-                + getBoundsInParent().getMinX();
-    }
-
+    @Override
     public void setDescriptionVisibility(DescriptionVisibility descrVis) {
-        this.descrVis = descrVis;
-        final int size = eventStripe.getEventIDs().size();
+        final int size = getEventStripe().getEventIDs().size();
 
-        switch (this.descrVis) {
+        switch (descrVis) {
             case HIDDEN:
                 countLabel.setText("");
                 descrLabel.setText("");
@@ -518,7 +129,7 @@ public void setDescriptionVisibility(DescriptionVisibility descrVis) {
                 break;
             default:
             case SHOWN:
-                String description = eventStripe.getDescription();
+                String description = getEventStripe().getDescription();
                 description = parentNode != null
                         ? "    ..." + StringUtils.substringAfter(description, parentNode.getDescription())
                         : description;
@@ -528,10 +139,6 @@ public void setDescriptionVisibility(DescriptionVisibility descrVis) {
         }
     }
 
-    Set<Long> getEventsIDs() {
-        return eventStripe.getEventIDs();
-    }
-
     /**
      * event handler used for mouse events on {@link EventStripeNode}s
      */
@@ -543,19 +150,13 @@ private class MouseClickHandler implements EventHandler<MouseEvent> {
         public void handle(MouseEvent t) {
 
             if (t.getButton() == MouseButton.PRIMARY) {
-                t.consume();
+             
                 if (t.isShiftDown()) {
                     if (chart.selectedNodes.contains(EventStripeNode.this) == false) {
                         chart.selectedNodes.add(EventStripeNode.this);
                     }
                 } else if (t.isShortcutDown()) {
                     chart.selectedNodes.removeAll(EventStripeNode.this);
-                } else if (t.getClickCount() > 1) {
-                    final DescriptionLoD next = descLOD.get().moreDetailed();
-                    if (next != null) {
-                        loadSubBundles(DescriptionLoD.RelativeDetail.MORE);
-
-                    }
                 } else {
                     chart.selectedNodes.setAll(EventStripeNode.this);
                 }
@@ -566,10 +167,9 @@ public void handle(MouseEvent t) {
                     contextMenu = new ContextMenu();
                     contextMenu.setAutoHide(true);
 
-                    contextMenu.getItems().add(ActionUtils.createMenuItem(new ExpandClusterAction()));
-                    contextMenu.getItems().add(ActionUtils.createMenuItem(new CollapseClusterAction()));
-
-                    contextMenu.getItems().add(new SeparatorMenuItem());
+                    EventDetailChart.HideDescriptionAction hideClusterAction = chart.new HideDescriptionAction(getDescription(), eventBundle.getDescriptionLoD());
+                    MenuItem hideDescriptionMenuItem = ActionUtils.createMenuItem(hideClusterAction);
+                    contextMenu.getItems().addAll(hideDescriptionMenuItem);
                     contextMenu.getItems().addAll(chartContextMenu.getItems());
                 }
                 contextMenu.show(EventStripeNode.this, t.getScreenX(), t.getScreenY());
@@ -578,38 +178,4 @@ public void handle(MouseEvent t) {
         }
     }
 
-    private class ExpandClusterAction extends Action {
-
-        @NbBundle.Messages("ExpandClusterAction.text=Expand")
-        ExpandClusterAction() {
-            super(Bundle.ExpandClusterAction_text());
-
-            setGraphic(new ImageView(PLUS));
-            setEventHandler((ActionEvent t) -> {
-                final DescriptionLoD next = descLOD.get().moreDetailed();
-                if (next != null) {
-                    loadSubBundles(DescriptionLoD.RelativeDetail.MORE);
-
-                }
-            });
-            disabledProperty().bind(descLOD.isEqualTo(DescriptionLoD.FULL));
-        }
-    }
-
-    private class CollapseClusterAction extends Action {
-
-        @NbBundle.Messages("CollapseClusterAction.text=Collapse")
-        CollapseClusterAction() {
-            super(Bundle.CollapseClusterAction_text());
-
-            setGraphic(new ImageView(MINUS));
-            setEventHandler((ActionEvent t) -> {
-                final DescriptionLoD previous = descLOD.get().lessDetailed();
-                if (previous != null) {
-                    loadSubBundles(DescriptionLoD.RelativeDetail.LESS);
-                }
-            });
-            disabledProperty().bind(Bindings.createBooleanBinding(() -> nonNull(eventStripe) && descLOD.get() == eventStripe.getDescriptionLoD(), descLOD));
-        }
-    }
 }
diff --git a/Core/src/org/sleuthkit/autopsy/timeline/ui/detailview/tree/EventDescriptionTreeItem.java b/Core/src/org/sleuthkit/autopsy/timeline/ui/detailview/tree/EventDescriptionTreeItem.java
index b3e80eb82793513b9e3a9abd293c2cca11245ef7..6feef28f8f8836ad71c3e5174fbf7af5c4b2d451 100644
--- a/Core/src/org/sleuthkit/autopsy/timeline/ui/detailview/tree/EventDescriptionTreeItem.java
+++ b/Core/src/org/sleuthkit/autopsy/timeline/ui/detailview/tree/EventDescriptionTreeItem.java
@@ -35,13 +35,13 @@ class EventDescriptionTreeItem extends NavTreeItem {
      * maps a description to the child item of this item with that description
      */
     private final Map<String, EventDescriptionTreeItem> childMap = new ConcurrentHashMap<>();
-    private final EventBundle bundle;
+    private final EventBundle<?> bundle;
 
-    public EventBundle getEventBundle() {
+    public EventBundle<?> getEventBundle() {
         return bundle;
     }
 
-    EventDescriptionTreeItem(EventBundle g) {
+    EventDescriptionTreeItem(EventBundle<?> g) {
         bundle = g;
         setValue(g);
     }
@@ -51,8 +51,8 @@ public long getCount() {
         return getValue().getCount();
     }
 
-    public void insert(Deque<EventBundle> path) {
-        EventBundle head = path.removeFirst();
+    public void insert(Deque<EventBundle<?>> path) {
+        EventBundle<?> head = path.removeFirst();
         EventDescriptionTreeItem treeItem = childMap.get(head.getDescription());
         if (treeItem == null) {
             treeItem = new EventDescriptionTreeItem(head);
@@ -68,12 +68,12 @@ public void insert(Deque<EventBundle> path) {
     }
 
     @Override
-    public void resort(Comparator<TreeItem<EventBundle>> comp) {
+    public void resort(Comparator<TreeItem<EventBundle<?>>> comp) {
         FXCollections.sort(getChildren(), comp);
     }
 
     @Override
-    public NavTreeItem findTreeItemForEvent(EventBundle t) {
+    public NavTreeItem findTreeItemForEvent(EventBundle<?> t) {
 
         if (getValue().getEventType() == t.getEventType()
                 && getValue().getDescription().equals(t.getDescription())) {
diff --git a/Core/src/org/sleuthkit/autopsy/timeline/ui/detailview/tree/EventTypeTreeItem.java b/Core/src/org/sleuthkit/autopsy/timeline/ui/detailview/tree/EventTypeTreeItem.java
index f1b91bf202f889afc0755433ffd4f38a0e427f62..e9219911918edf1231af9681110f8d392e00227a 100644
--- a/Core/src/org/sleuthkit/autopsy/timeline/ui/detailview/tree/EventTypeTreeItem.java
+++ b/Core/src/org/sleuthkit/autopsy/timeline/ui/detailview/tree/EventTypeTreeItem.java
@@ -33,9 +33,9 @@ class EventTypeTreeItem extends NavTreeItem {
      */
     private final Map<String, EventDescriptionTreeItem> childMap = new ConcurrentHashMap<>();
 
-    private final Comparator<TreeItem<EventBundle>> comparator = TreeComparator.Description;
+    private final Comparator<TreeItem<EventBundle<?>>> comparator = TreeComparator.Description;
 
-    EventTypeTreeItem(EventBundle g) {
+    EventTypeTreeItem(EventBundle<?> g) {
         setValue(g);
     }
 
@@ -44,8 +44,8 @@ public long getCount() {
         return getValue().getCount();
     }
 
-    public void insert(Deque<EventBundle> path) {
-        EventBundle head = path.removeFirst();
+    public void insert(Deque<EventBundle<?>> path) {
+        EventBundle<?> head = path.removeFirst();
         EventDescriptionTreeItem treeItem = childMap.get(head.getDescription());
         if (treeItem == null) {
             treeItem = new EventDescriptionTreeItem(head);
@@ -61,7 +61,7 @@ public void insert(Deque<EventBundle> path) {
     }
 
     @Override
-    public NavTreeItem findTreeItemForEvent(EventBundle t) {
+    public NavTreeItem findTreeItemForEvent(EventBundle<?> t) {
         if (t.getEventType().getBaseType() == getValue().getEventType().getBaseType()) {
 
             for (EventDescriptionTreeItem child : childMap.values()) {
@@ -75,7 +75,7 @@ public NavTreeItem findTreeItemForEvent(EventBundle t) {
     }
 
     @Override
-    public void resort(Comparator<TreeItem<EventBundle>> comp) {
+    public void resort(Comparator<TreeItem<EventBundle<?>>> comp) {
         FXCollections.sort(getChildren(), comp);
     }
 }
diff --git a/Core/src/org/sleuthkit/autopsy/timeline/ui/detailview/tree/NavPanel.java b/Core/src/org/sleuthkit/autopsy/timeline/ui/detailview/tree/NavPanel.java
index 9b8378ed6b3a63e00fc83fda344a7a9ea925be75..7b96a0ea534d012f5eab5ca9f18dd331b0bcf25b 100644
--- a/Core/src/org/sleuthkit/autopsy/timeline/ui/detailview/tree/NavPanel.java
+++ b/Core/src/org/sleuthkit/autopsy/timeline/ui/detailview/tree/NavPanel.java
@@ -68,13 +68,13 @@ public class NavPanel extends BorderPane implements TimeLineView {
     private DetailViewPane detailViewPane;
 
     @FXML
-    private TreeView<EventBundle> eventsTree;
+    private TreeView<EventBundle<?>> eventsTree;
 
     @FXML
     private Label eventsTreeLabel;
 
     @FXML
-    private ComboBox<Comparator<TreeItem<EventBundle>>> sortByBox;
+    private ComboBox<Comparator<TreeItem<EventBundle<?>>>> sortByBox;
 
     public NavPanel() {
         FXMLConstructor.construct(this, "NavPanel.fxml"); // NON-NLS 
@@ -91,8 +91,8 @@ public void setDetailViewPane(DetailViewPane detailViewPane) {
 
         detailViewPane.getSelectedNodes().addListener((Observable observable) -> {
             eventsTree.getSelectionModel().clearSelection();
-            detailViewPane.getSelectedNodes().forEach(eventStripeNode -> {
-                eventsTree.getSelectionModel().select(getRoot().findTreeItemForEvent(eventStripeNode.getEventStripe()));
+            detailViewPane.getSelectedNodes().forEach(eventBundleNode -> {
+                eventsTree.getSelectionModel().select(getRoot().findTreeItemForEvent(eventBundleNode.getEventBundle()));
             });
         });
 
@@ -105,7 +105,7 @@ private NavTreeItem getRoot() {
     @ThreadConfined(type = ThreadConfined.ThreadType.JFX)
     private void setRoot() {
         RootItem root = new RootItem();
-        for (EventBundle bundle : detailViewPane.getEventBundles()) {
+        for (EventBundle<?> bundle : detailViewPane.getEventBundles()) {
             root.insert(bundle);
         }
         eventsTree.setRoot(root);
@@ -134,7 +134,7 @@ void initialize() {
             getRoot().resort(sortByBox.getSelectionModel().getSelectedItem());
         });
         eventsTree.setShowRoot(false);
-        eventsTree.setCellFactory((TreeView<EventBundle> p) -> new EventBundleTreeCell());
+        eventsTree.setCellFactory((TreeView<EventBundle<?>> p) -> new EventBundleTreeCell());
         eventsTree.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
 
         eventsTreeLabel.setText(NbBundle.getMessage(this.getClass(), "NavPanel.eventsTreeLabel.text"));
@@ -144,7 +144,7 @@ void initialize() {
      * A tree cell to display {@link EventBundle}s. Shows the description, and
      * count, as well a a "legend icon" for the event type.
      */
-    private class EventBundleTreeCell extends TreeCell<EventBundle> {
+    private class EventBundleTreeCell extends TreeCell<EventBundle<?>> {
 
         private static final double HIDDEN_MULTIPLIER = .6;
         private final Rectangle rect = new Rectangle(24, 24);
@@ -158,7 +158,7 @@ private class EventBundleTreeCell extends TreeCell<EventBundle> {
         }
 
         @Override
-        protected void updateItem(EventBundle item, boolean empty) {
+        protected void updateItem(EventBundle<?> item, boolean empty) {
             super.updateItem(item, empty);
             if (item == null || empty) {
                 setText(null);
@@ -177,7 +177,7 @@ protected void updateItem(EventBundle item, boolean empty) {
                 });
                 registerListeners(controller.getQuickHideFilters(), item);
                 String text = item.getDescription() + " (" + item.getCount() + ")"; // NON-NLS
-                TreeItem<EventBundle> parent = getTreeItem().getParent();
+                TreeItem<EventBundle<?>> parent = getTreeItem().getParent();
                 if (parent != null && parent.getValue() != null && (parent instanceof EventDescriptionTreeItem)) {
                     text = StringUtils.substringAfter(text, parent.getValue().getDescription());
                 }
@@ -189,7 +189,7 @@ protected void updateItem(EventBundle item, boolean empty) {
             }
         }
 
-        private void registerListeners(Collection<? extends DescriptionFilter> filters, EventBundle item) {
+        private void registerListeners(Collection<? extends DescriptionFilter> filters, EventBundle<?> item) {
             for (DescriptionFilter filter : filters) {
                 if (filter.getDescription().equals(item.getDescription())) {
                     filter.activeProperty().addListener(filterStateChangeListener);
@@ -205,8 +205,8 @@ private void deRegisterListeners(Collection<? extends DescriptionFilter> filters
             }
         }
 
-        private void updateHiddenState(EventBundle item) {
-            TreeItem<EventBundle> treeItem = getTreeItem();
+        private void updateHiddenState(EventBundle<?> item) {
+            TreeItem<EventBundle<?>> treeItem = getTreeItem();
             ContextMenu newMenu;
             if (controller.getQuickHideFilters().stream().
                     filter(AbstractFilter::isActive)
diff --git a/Core/src/org/sleuthkit/autopsy/timeline/ui/detailview/tree/NavTreeItem.java b/Core/src/org/sleuthkit/autopsy/timeline/ui/detailview/tree/NavTreeItem.java
index d1df7a5b3874073150afc22417fdef8b6c0b947d..bed99d2270cb8e5e66869450183c3bbc18ec5282 100644
--- a/Core/src/org/sleuthkit/autopsy/timeline/ui/detailview/tree/NavTreeItem.java
+++ b/Core/src/org/sleuthkit/autopsy/timeline/ui/detailview/tree/NavTreeItem.java
@@ -28,11 +28,11 @@
  * {@link EventTreeCell}. Each NavTreeItem has a EventBundle which has a type,
  * description , count, etc.
  */
-abstract class NavTreeItem extends TreeItem<EventBundle> {
+abstract class NavTreeItem extends TreeItem<EventBundle<?>> {
 
     abstract long getCount();
 
-    abstract void resort(Comparator<TreeItem<EventBundle>> comp);
+    abstract void resort(Comparator<TreeItem<EventBundle<?>>> comp);
 
-    abstract NavTreeItem findTreeItemForEvent(EventBundle t);
+    abstract NavTreeItem findTreeItemForEvent(EventBundle<?> t);
 }
diff --git a/Core/src/org/sleuthkit/autopsy/timeline/ui/detailview/tree/RootItem.java b/Core/src/org/sleuthkit/autopsy/timeline/ui/detailview/tree/RootItem.java
index a96dfaae20d45bc34d615644499fa2fbb44aa803..571758a03747ccdf369b283c41f8c3f86abda453 100644
--- a/Core/src/org/sleuthkit/autopsy/timeline/ui/detailview/tree/RootItem.java
+++ b/Core/src/org/sleuthkit/autopsy/timeline/ui/detailview/tree/RootItem.java
@@ -56,7 +56,7 @@ public long getCount() {
      *
      * @param g Group to add
      */
-    public void insert(EventBundle g) {
+    public void insert(EventBundle<?> g) {
 
         EventTypeTreeItem treeItem = childMap.computeIfAbsent(g.getEventType().getBaseType(),
                 baseType -> {
@@ -69,12 +69,12 @@ public void insert(EventBundle g) {
         treeItem.insert(getTreePath(g));
     }
 
-    static Deque<EventBundle> getTreePath(EventBundle g) {
-        Deque<EventBundle> path = new ArrayDeque<>();
-        Optional<EventBundle> p = Optional.of(g);
+    static Deque< EventBundle<?>> getTreePath(EventBundle<?> g) {
+        Deque<EventBundle<?>> path = new ArrayDeque<>();
+        Optional<? extends EventBundle<?>> p = Optional.of(g);
 
         while (p.isPresent()) {
-            EventBundle parent = p.get();
+            EventBundle<?> parent = p.get();
             path.addFirst(parent);
             p = parent.getParentBundle();
         }
@@ -83,12 +83,12 @@ static Deque<EventBundle> getTreePath(EventBundle g) {
     }
 
     @Override
-    public void resort(Comparator<TreeItem<EventBundle>> comp) {
+    public void resort(Comparator<TreeItem<EventBundle<?>>> comp) {
         childMap.values().forEach(ti -> ti.resort(comp));
     }
 
     @Override
-    public NavTreeItem findTreeItemForEvent(EventBundle t) {
+    public NavTreeItem findTreeItemForEvent(EventBundle<?> t) {
         for (EventTypeTreeItem child : childMap.values()) {
             final NavTreeItem findTreeItemForEvent = child.findTreeItemForEvent(t);
             if (findTreeItemForEvent != null) {
diff --git a/Core/src/org/sleuthkit/autopsy/timeline/ui/detailview/tree/TreeComparator.java b/Core/src/org/sleuthkit/autopsy/timeline/ui/detailview/tree/TreeComparator.java
index aad194f61cc0e1c1b40288771e13191d6716358b..195a286ed93447d7adf98bb9dbfe51706aa1d80c 100644
--- a/Core/src/org/sleuthkit/autopsy/timeline/ui/detailview/tree/TreeComparator.java
+++ b/Core/src/org/sleuthkit/autopsy/timeline/ui/detailview/tree/TreeComparator.java
@@ -23,23 +23,23 @@
 import org.sleuthkit.autopsy.timeline.datamodel.EventBundle;
 import org.sleuthkit.autopsy.timeline.datamodel.eventtype.EventType;
 
-enum TreeComparator implements Comparator<TreeItem<EventBundle>> {
+enum TreeComparator implements Comparator<TreeItem<EventBundle<?>>> {
 
     Description {
                 @Override
-                public int compare(TreeItem<EventBundle> o1, TreeItem<EventBundle> o2) {
+                public int compare(TreeItem<EventBundle<?>> o1, TreeItem<EventBundle<?>> o2) {
                     return o1.getValue().getDescription().compareTo(o2.getValue().getDescription());
                 }
             },
     Count {
                 @Override
-                public int compare(TreeItem<EventBundle> o1, TreeItem<EventBundle> o2) {
+                public int compare(TreeItem<EventBundle<?>> o1, TreeItem<EventBundle<?>> o2) {
                     return Long.compare(o2.getValue().getCount(), o1.getValue().getCount());
                 }
             },
     Type {
                 @Override
-                public int compare(TreeItem<EventBundle> o1, TreeItem<EventBundle> o2) {
+                public int compare(TreeItem<EventBundle<?>> o1, TreeItem<EventBundle<?>> o2) {
                     return EventType.getComparator().compare(o1.getValue().getEventType(), o2.getValue().getEventType());
                 }
             };
diff --git a/Core/src/org/sleuthkit/autopsy/timeline/zooming/DescriptionLoD.java b/Core/src/org/sleuthkit/autopsy/timeline/zooming/DescriptionLoD.java
index 50d612b210dd3d8caad0c3a8958943a33dd502ff..0fab0442c80768c2be7207c2ecd63bafbda7df93 100644
--- a/Core/src/org/sleuthkit/autopsy/timeline/zooming/DescriptionLoD.java
+++ b/Core/src/org/sleuthkit/autopsy/timeline/zooming/DescriptionLoD.java
@@ -43,7 +43,7 @@ public DescriptionLoD moreDetailed() {
         try {
             return values()[ordinal() + 1];
         } catch (ArrayIndexOutOfBoundsException e) {
-            return null;
+            return FULL;
         }
     }
 
@@ -51,7 +51,7 @@ public DescriptionLoD lessDetailed() {
         try {
             return values()[ordinal() - 1];
         } catch (ArrayIndexOutOfBoundsException e) {
-            return null;
+            return SHORT;
         }
     }