tnt.board
Version:
TnT track-based board display
545 lines (466 loc) • 15.7 kB
JavaScript
var apijs = require ("tnt.api");
var deferCancel = require ("tnt.utils").defer_cancel;
var board = function() {
"use strict";
//// Private vars
var svg;
var div_id;
var tracks = [];
var min_width = 50;
var height = 0; // This is the global height including all the tracks
var width = 920;
var height_offset = 20;
var loc = {
species : undefined,
chr : undefined,
from : 0,
to : 500
};
// Limit caps
var caps = {
left : undefined,
right : undefined
};
var cap_width = 3;
// TODO: We have now background color in the tracks. Can this be removed?
// It looks like it is used in the too-wide pane etc, but it may not be needed anymore
var bgColor = d3.rgb('#F8FBEF'); //#F8FBEF
var pane; // Draggable pane
var svg_g;
var xScale;
var zoomEventHandler = d3.behavior.zoom();
var limits = {
min : 0,
max : 1000,
zoom_out : 1000,
zoom_in : 100
};
var dur = 500;
var drag_allowed = true;
var exports = {
ease : d3.ease("cubic-in-out"),
extend_canvas : {
left : 0,
right : 0
},
show_frame : true
// limits : function () {throw "The limits method should be defined"}
};
// The returned closure / object
var track_vis = function(div) {
div_id = d3.select(div).attr("id");
// The original div is classed with the tnt class
d3.select(div)
.classed("tnt", true);
// TODO: Move the styling to the scss?
var browserDiv = d3.select(div)
.append("div")
.attr("id", "tnt_" + div_id)
.style("position", "relative")
.classed("tnt_framed", exports.show_frame ? true : false)
.style("width", (width + cap_width*2 + exports.extend_canvas.right + exports.extend_canvas.left) + "px");
var groupDiv = browserDiv
.append("div")
.attr("class", "tnt_groupDiv");
// The SVG
svg = groupDiv
.append("svg")
.attr("class", "tnt_svg")
.attr("width", width)
.attr("height", height)
.attr("pointer-events", "all");
svg_g = svg
.append("g")
.attr("transform", "translate(0,20)")
.append("g")
.attr("class", "tnt_g");
// caps
caps.left = svg_g
.append("rect")
.attr("id", "tnt_" + div_id + "_5pcap")
.attr("x", 0)
.attr("y", 0)
.attr("width", 0)
.attr("height", height)
.attr("fill", "red");
caps.right = svg_g
.append("rect")
.attr("id", "tnt_" + div_id + "_3pcap")
.attr("x", width-cap_width)
.attr("y", 0)
.attr("width", 0)
.attr("height", height)
.attr("fill", "red");
// The Zooming/Panning Pane
pane = svg_g
.append("rect")
.attr("class", "tnt_pane")
.attr("id", "tnt_" + div_id + "_pane")
.attr("width", width)
.attr("height", height)
.style("fill", bgColor);
// ** TODO: Wouldn't be better to have these messages by track?
// var tooWide_text = svg_g
// .append("text")
// .attr("class", "tnt_wideOK_text")
// .attr("id", "tnt_" + div_id + "_tooWide")
// .attr("fill", bgColor)
// .text("Region too wide");
// TODO: I don't know if this is the best way (and portable) way
// of centering the text in the text area
// var bb = tooWide_text[0][0].getBBox();
// tooWide_text
// .attr("x", ~~(width/2 - bb.width/2))
// .attr("y", ~~(height/2 - bb.height/2));
};
// API
var api = apijs (track_vis)
.getset (exports)
.getset (limits)
.getset (loc);
api.transform (track_vis.extend_canvas, function (val) {
var prev_val = track_vis.extend_canvas();
val.left = val.left || prev_val.left;
val.right = val.right || prev_val.right;
return val;
});
// track_vis always starts on loc.from & loc.to
api.method ('start', function () {
// make sure that zoom_out is within the min-max range
if ((limits.max - limits.min) < limits.zoom_out) {
limits.zoom_out = limits.max - limits.min;
}
plot();
// Reset the tracks
for (var i=0; i<tracks.length; i++) {
if (tracks[i].g) {
// tracks[i].display().reset.call(tracks[i]);
tracks[i].g.remove();
}
_init_track(tracks[i]);
}
_place_tracks();
// The continuation callback
var cont = function () {
if ((loc.to - loc.from) < limits.zoom_in) {
if ((loc.from + limits.zoom_in) > limits.max) {
loc.to = limits.max;
} else {
loc.to = loc.from + limits.zoom_in;
}
}
for (var i=0; i<tracks.length; i++) {
_update_track(tracks[i], loc);
}
};
cont();
});
api.method ('update', function () {
for (var i=0; i<tracks.length; i++) {
_update_track (tracks[i]);
}
});
var _update_track = function (track, where) {
if (track.data()) {
var track_data = track.data();
var data_updater = track_data;
data_updater.call(track, {
'loc' : where,
'on_success' : function () {
track.display().update.call(track, where);
}
});
}
};
var plot = function() {
xScale = d3.scale.linear()
.domain([loc.from, loc.to])
.range([0, width]);
if (drag_allowed) {
svg_g.call( zoomEventHandler
.x(xScale)
.scaleExtent([(loc.to-loc.from)/(limits.zoom_out-1), (loc.to-loc.from)/limits.zoom_in])
.on("zoom", _move)
);
}
};
var _reorder = function (new_tracks) {
// TODO: This is defining a new height, but the global height is used to define the size of several
// parts. We should do this dynamically
var found_indexes = [];
for (var j=0; j<new_tracks.length; j++) {
var found = false;
for (var i=0; i<tracks.length; i++) {
if (tracks[i].id() === new_tracks[j].id()) {
found = true;
found_indexes[i] = true;
// tracks.splice(i,1);
break;
}
}
if (!found) {
_init_track(new_tracks[j]);
_update_track(new_tracks[j], {from : loc.from, to : loc.to});
}
}
for (var x=0; x<tracks.length; x++) {
if (!found_indexes[x]) {
tracks[x].g.remove();
}
}
tracks = new_tracks;
_place_tracks();
};
// right/left/zoom pans or zooms the track. These methods are exposed to allow external buttons, etc to interact with the tracks. The argument is the amount of panning/zooming (ie. 1.2 means 20% panning) With left/right only positive numbers are allowed.
api.method ('scroll', function (factor) {
var amount = Math.abs(factor);
if (factor > 0) {
_manual_move(amount, 1);
} else if (factor < 0){
_manual_move(amount, -1);
}
});
api.method ('zoom', function (factor) {
_manual_move(1/factor, 0);
});
api.method ('find_track', function (id) {
for (var i=0; i<tracks.length; i++) {
if (tracks[i].id() === id) {
return tracks[i];
}
}
});
api.method ('remove_track', function (track) {
track.g.remove();
});
api.method ('add_track', function (track) {
if (track instanceof Array) {
for (var i=0; i<track.length; i++) {
track_vis.add_track (track[i]);
}
return track_vis;
}
tracks.push(track);
return track_vis;
});
api.method('tracks', function (ts) {
if (!arguments.length) {
return tracks;
}
_reorder(ts);
return this;
});
//
api.method ('width', function (w) {
// TODO: Allow suffixes like "1000px"?
// TODO: Test wrong formats
if (!arguments.length) {
return width;
}
// At least min-width
if (w < min_width) {
w = min_width;
}
// We are resizing
if (div_id !== undefined) {
d3.select("#tnt_" + div_id).select("svg").attr("width", w);
// Resize the zooming/panning pane
d3.select("#tnt_" + div_id).style("width", (parseInt(w) + cap_width*2) + "px");
d3.select("#tnt_" + div_id + "_pane").attr("width", w);
caps.right
.attr("x", w-cap_width);
// Replot
width = w;
xScale.range([0, width]);
plot();
for (var i=0; i<tracks.length; i++) {
tracks[i].g.select("rect").attr("width", w);
tracks[i].display().scale(xScale);
tracks[i].display().reset.call(tracks[i]);
tracks[i].display().init.call(tracks[i], w);
tracks[i].display().update.call(tracks[i], loc);
}
} else {
width = w;
}
return track_vis;
});
api.method('allow_drag', function(b) {
if (!arguments.length) {
return drag_allowed;
}
drag_allowed = b;
if (drag_allowed) {
// When this method is called on the object before starting the simulation, we don't have defined xScale
if (xScale !== undefined) {
svg_g.call( zoomEventHandler.x(xScale)
// .xExtent([0, limits.right])
.scaleExtent([(loc.to-loc.from)/(limits.zoom_out-1), (loc.to-loc.from)/limits.zoom_in])
.on("zoom", _move) );
}
} else {
// We create a new dummy scale in x to avoid dragging the previous one
// TODO: There may be a cheaper way of doing this?
zoomEventHandler.x(d3.scale.linear()).on("zoom", null);
}
return track_vis;
});
var _place_tracks = function () {
var h = 0;
for (var i=0; i<tracks.length; i++) {
var track = tracks[i];
if (track.g.attr("transform")) {
track.g
.transition()
.duration(dur)
.attr("transform", "translate(" + exports.extend_canvas.left + "," + h + ")");
} else {
track.g
.attr("transform", "translate(" + exports.extend_canvas.left + "," + h + ")");
}
h += track.height();
}
// svg
svg.attr("height", h + height_offset);
// div
d3.select("#tnt_" + div_id)
.style("height", (h + 10 + height_offset) + "px");
// caps
d3.select("#tnt_" + div_id + "_5pcap")
.attr("height", h)
.each(function (d) {
move_to_front(this);
});
d3.select("#tnt_" + div_id + "_3pcap")
.attr("height", h)
.each (function (d) {
move_to_front(this);
});
// pane
pane
.attr("height", h + height_offset);
return track_vis;
};
var _init_track = function (track) {
track.g = svg.select("g").select("g")
.append("g")
.attr("class", "tnt_track")
.attr("height", track.height());
// Rect for the background color
track.g
.append("rect")
.attr("x", 0)
.attr("y", 0)
.attr("width", track_vis.width())
.attr("height", track.height())
.style("fill", track.color())
.style("pointer-events", "none");
if (track.display()) {
track.display()
.scale(xScale)
.init.call(track, width);
}
return track_vis;
};
var _manual_move = function (factor, direction) {
var oldDomain = xScale.domain();
var span = oldDomain[1] - oldDomain[0];
var offset = (span * factor) - span;
var newDomain;
switch (direction) {
case 1 :
newDomain = [(~~oldDomain[0] - offset), ~~(oldDomain[1] - offset)];
break;
case -1 :
newDomain = [(~~oldDomain[0] + offset), ~~(oldDomain[1] - offset)];
break;
case 0 :
newDomain = [oldDomain[0] - ~~(offset/2), oldDomain[1] + (~~offset/2)];
}
var interpolator = d3.interpolateNumber(oldDomain[0], newDomain[0]);
var ease = exports.ease;
var x = 0;
d3.timer(function() {
var curr_start = interpolator(ease(x));
var curr_end;
switch (direction) {
case -1 :
curr_end = curr_start + span;
break;
case 1 :
curr_end = curr_start + span;
break;
case 0 :
curr_end = oldDomain[1] + oldDomain[0] - curr_start;
break;
}
var currDomain = [curr_start, curr_end];
xScale.domain(currDomain);
_move(xScale);
x+=0.02;
return x>1;
});
};
var _move_cbak = function () {
var currDomain = xScale.domain();
track_vis.from(~~currDomain[0]);
track_vis.to(~~currDomain[1]);
for (var i = 0; i < tracks.length; i++) {
var track = tracks[i];
_update_track(track, loc);
}
};
// The deferred_cbak is deferred at least this amount of time or re-scheduled if deferred is called before
var _deferred = deferCancel(_move_cbak, 300);
// api.method('update', function () {
// _move();
// });
var _move = function (new_xScale) {
if (new_xScale !== undefined && drag_allowed) {
zoomEventHandler.x(new_xScale);
}
// Show the red bars at the limits
var domain = xScale.domain();
if (domain[0] <= (limits.min + 5)) {
d3.select("#tnt_" + div_id + "_5pcap")
.attr("width", cap_width)
.transition()
.duration(200)
.attr("width", 0);
}
if (domain[1] >= (limits.max)-5) {
d3.select("#tnt_" + div_id + "_3pcap")
.attr("width", cap_width)
.transition()
.duration(200)
.attr("width", 0);
}
// Avoid moving past the limits
if (domain[0] < limits.min) {
zoomEventHandler.translate([zoomEventHandler.translate()[0] - xScale(limits.min) + xScale.range()[0], zoomEventHandler.translate()[1]]);
} else if (domain[1] > limits.max) {
zoomEventHandler.translate([zoomEventHandler.translate()[0] - xScale(limits.max) + xScale.range()[1], zoomEventHandler.translate()[1]]);
}
_deferred();
for (var i = 0; i < tracks.length; i++) {
var track = tracks[i];
track.display().mover.call(track);
}
};
// api.method({
// allow_drag : api_allow_drag,
// width : api_width,
// add_track : api_add_track,
// reorder : api_reorder,
// zoom : api_zoom,
// left : api_left,
// right : api_right,
// start : api_start
// });
// Auxiliar functions
function move_to_front (elem) {
elem.parentNode.appendChild(elem);
}
return track_vis;
};
module.exports = exports = board;