Drag & Drop to Modify Axis Hierarchies

Tuesday, May 28, 2013
Being able to drag and drop hierarchies to reorder them is another step to remove extra controls used in my JSF example and provide as much MDX query functionallity as possible using only the cell set table. The logical choice to implement drag & drop was using jquery-ui draggable and droppable widgets. Initial experiments made evident that the current columns axis layout is not a good fit for a drag & drop interface to hierarchy reorder, so I've redisigned its layout.

Columns Header Layout

I've never been a fan of the current columns header layout, using a full row for each single hierarchy header seems like a screen real estate waste. Furthermore the interleaved hierarchy names and members make harder to provide visual clues to a hierarchy dragging operation. After looking around for a more compact layout solution, I've finally ended up creating a totally new design. The idea was to keep the columns hierarchies together and, as close as possible to the rows hierarchies. My solution was to pile up the columns hierarchies headers on top of the rows hierarchies and provide a visual clue for the user to properly link each hierarchy label with the corresponding hierarchy members.

The hard part of it was to draw the slides linking each hierarchy with its members. You can't use fixed sized images, because when they are scaled to fit the cell height the line width is also scaled. I'm using dinamically generated images to get lines with the proper line width. This is how it works:
  • Each slide has its own cell with .cgaoSlide class (rows axis header has to compensate for this)
  • The last step in the cell set table generation calls drawSlides(table)
  • drawSlides sets the cell background with an image named after the current cell width and height
  • A dedicated Java servlet draws the slide with the correct size
This is the Javascript for drawSlides
function drawSlides(table) {
  var slides = $('th.cgaoSlide');
  var i;
  for( i = 0; i < slides.size(); i++ ) {
    var first = slides.get(i);
    slides.css('background','url("imgd/line_'+ first.offsetWidth+'_'+ (first.offsetHeight)+'.png") no-repeat');
    slides.css('background-width','100%');
    slides.css('background-height','100%');
  }
}  
And the Servlet generating the images. Note that I've disabled antialiasing, otherwise the line may be be antialiased twice resulting in a blurred line.
public class TableBorderImage extends HttpServlet
{
  protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
    String name = request.getRequestURI().substring(request.getContextPath().length()+1);
    String[] nameParts = name.split("[_\\.]");

    int width = Integer.parseInt(nameParts[1]);
    int height = Integer.parseInt(nameParts[2]);

    BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
    Graphics2D gr = (Graphics2D) image.getGraphics();
    gr.setRenderingHint(
      RenderingHints.KEY_ANTIALIASING,
      RenderingHints.VALUE_ANTIALIAS_OFF);
    gr.setColor(Color.WHITE);
    gr.fillRect(0, 0, width+1, height);

    gr.setColor(new Color(0xdd,0xdd,0xdd));
    Path2D.Double path = new Path2D.Double();
    path.moveTo(0,0);
    path.curveTo(width/2,0,width/2,height/4,width/2,height/2);
    path.curveTo(width/2,3*height/4,width/2,height,width-1,height-1);
    gr.draw(path);
    response.setContentType("image/png");
    ImageIO.write(image, "PNG", response.getOutputStream());
  }
}

Visual Clue for Drop Point

jquery-ui uses draggable widgets to designate elements that can be dragged, and droppable widgets to designate elements that can be dropped. In my implementation I make <th> elements for hierarchy labes both, draggable and droppable. Unfortunately when you drop a hierarchy over another one, the insertion point is not totally defined. It can be inserted before or after the later.

The idea is to insert the hierarchy after, when the user drops the dragged hierarchy on the right side of the hierarchy, and insert it before when dropped on the left side (change left by top and right by bottom for hierarchies in the columns axis). The insertion point lights up as the user drags the hierarchy.

To achieve this I'm using two jQuery events:

droppable.over
to set the currently dragged-on object as a data property of the droppable
function onHierarchyDragOver(event,ui){
  ui.helper.data('over', $(event.target));
}
draggable.drag
to track the drag operation and set css classes based on the dragged element position relative to the current dragged-on object. CSS styles highlight the right border based on the axis (.colHierarchy/.rowHierarchy) class and the dragged element relative postion class(.cgaoDropLeft, .cgaoDropRight, .cgaoDropTop, .cgaoDropBottom)
function onHierarchyDrag(event, ui){
  var over = ui.helper.data('over');
  if ( over ) {
    var pos = within(over,event.pageX, event.pageY);
    if ( !pos ) {
      over.removeClass(dropHighlightH.join(' ')+dropHighlightV.join(' '));
    } else {
      over.addClass(dropHighlightH[pos[0]]+' '+dropHighlightV[pos[1]]);
      over.removeClass(dropHighlightH[(pos[0]+1)%2]+' '+dropHighlightV[(pos[1]+1)%2]);
    }
  }
}

var dropHighlightH = ['cgaoDropLeft','cgaoDropRight'];
var dropHighlightV= ['cgaoDropTop','cgaoDropBottom'];

function within(elem, x, y) {
  var left = elem.offset().left;
  var top = elem.offset().top;
  var width = elem.width();
  var height = elem.height();
  var result = null;
  if ( x >= left && (x-left <= width) &&
       y >= top && (y-top) <= height ) {
    var result = [];
    result.push(((x-left) < width/2) ? 0 : 1);
    result.push(((y-top) < height/2) ? 0 : 1);
  }
  return result;
}
th.rowHierarchy.cgaoDropLeft {
    border-left:3px solid #0088cc;
}
th.rowHierarchy.cgaoDropRight {
    border-right:3px solid #0088cc;
}
th.colHierarchy.cgaoDropTop {
    border-top:3px solid #0088cc;
}
th.colHierarchy.cgaoDropBottom {
    border-bottom:3px solid #0088cc;
}

And, finally, these are the calls to initialize the draggable/droppable THs containing the hierarchy labels:

var headers = table.find('.rowHierarchy,.colHierarchy');
headers.draggable({
  helper:function(e,ui){
    var src = $(e.currentTarget);
    return src.clone()
        .css('border','1px solid #dddddd')
        .css('background','rgba(255,255,255,0.5)')
  },
  revertDuration:0,
  revert:true,
  cursor:'pointer',
  drag:onHierarchyDrag
});
headers.droppable({
  accept:'.rowHierarchy,.colHierarchy',
  tolerance:'pointer',
  over:onHierarchyDragOver,
  out:onHierarchyDragOut,
  drop:function(event,ui){
    // Clears insertion point visual clues
    $(event.target).removeClass(dropHighlightH.concat(dropHighlightV).join(' '));

    // Retrieves hierarchy info for dropped and dragged-on elements
    var metadataFrom = parent[0].olapGetMetadataAt(ui.draggable);
    var dest = angular.element(this);
    var metadataTo = parent[0].olapGetMetadataAt(dest);

    var pos = 0;
    if ( metadataFrom ) {
      // Adjusts insertion point based on drop position
      if ( metadataTo.axisOrdinal === 0 ) {
        pos = dest.closest('tr').prevAll().length;
        pos += within(dest, event.pageX, event.pageY)[1];
      } else if ( metadataTo.axisOrdinal === 1 ) {
        pos += dest.get(0).cellIndex;
        pos += within(dest, event.pageX, event.pageY)[0];
      }

      // Executes hierarchy change
      move(metadataFrom.hierarchy.caption, metadataTo.axisOrdinal, pos);
    }
  }
});

Server Side Changes

The call on line 40 in the previous code snippet triggers an AJAX call to move the hierarchy to the desired position, updating the cell set accordingly. The QueryContoller method answering this request moves the hierarchy to the desired axis if needed, and uses QueryAxis#pullUp/QueryAxis#pushDown to move the hierarchy to its final position.

@POST
  @Path("hierarchies/move")
  @Produces(MediaType.APPLICATION_JSON)
  public QueryCellSet moveHierarchy(@FormParam("hierarchy") String hierarchyName,
                                    @FormParam("axis") int targetAxisOrdinal, @FormParam("position") int targetPos) {
    QueryHierarchy hierarchy = _query.getHierarchy(hierarchyName);
    QueryAxis axis = _query.getAxis(Axis.Factory.forOrdinal(targetAxisOrdinal));
    int hierarchySrcPos = axis.getHierarchies().indexOf(hierarchy);
    if ( hierarchySrcPos < 0 ) {
      axis.addHierarchy(hierarchy);
      hierarchySrcPos = axis.getHierarchies().size()-1;
    }
    if ( targetPos > hierarchySrcPos) {
      for(; targetPos > hierarchySrcPos; ++hierarchySrcPos ) {
        axis.pushDown(hierarchySrcPos);
      }
    } else if ( targetPos < hierarchySrcPos ) {
      for (; targetPos < hierarchySrcPos; --hierarchySrcPos) {
        axis.pullUp(hierarchySrcPos);
      }
    }

    return doExecuteQuery();
  }