// A timeline component for d3
// version v0.1
function timeline(domElement) {
//--------------------------------------------------------------------------
//
// chart
//
// chart geometry
var margin = {top: 20, right: 20, bottom: 20, left: 20},
outerWidth = 960,
outerHeight = 500,
width = outerWidth - margin.left - margin.right,
height = outerHeight - margin.top - margin.bottom;
// global timeline variables
var timeline = {}, // The timeline
data = {}, // Container for the data
components = [], // All the components of the timeline for redrawing
bandGap = 25, // Arbitray gap between to consecutive bands
bands = {}, // Registry for all the bands in the timeline
bandY = 0, // Y-Position of the next band
bandNum = 0; // Count of bands for ids
// Create svg element
var svg = d3.select(domElement).append("svg")
.attr("class", "svg")
.attr("id", "svg")
.attr("width", outerWidth)
.attr("height", outerHeight)
.append("g")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
svg.append("clipPath")
.attr("id", "chart-area")
.append("rect")
.attr("width", width)
.attr("height", height);
var chart = svg.append("g")
.attr("class", "chart")
.attr("clip-path", "url(#chart-area)" );
var tooltip = d3.select("body")
.append("div")
.attr("class", "tooltip")
.style("visibility", "visible");
//--------------------------------------------------------------------------
//
// data
//
timeline.data = function(items) {
var today = new Date(),
tracks = [],
yearMillis = 31622400000,
instantOffset = 100 * yearMillis;
data.items = items;
function showItems(n) {
var count = 0, n = n || 10;
console.log("\n");
items.forEach(function (d) {
count++;
if (count > n) return;
console.log(toYear(d.start) + " - " + toYear(d.end) + ": " + d.label);
})
}
function compareAscending(item1, item2) {
// Every item must have two fields: 'start' and 'end'.
var result = item1.start - item2.start;
// earlier first
if (result < 0) { return -1; }
if (result > 0) { return 1; }
// longer first
result = item2.end - item1.end;
if (result < 0) { return -1; }
if (result > 0) { return 1; }
return 0;
}
function compareDescending(item1, item2) {
// Every item must have two fields: 'start' and 'end'.
var result = item1.start - item2.start;
// later first
if (result < 0) { return 1; }
if (result > 0) { return -1; }
// shorter first
result = item2.end - item1.end;
if (result < 0) { return 1; }
if (result > 0) { return -1; }
return 0;
}
function calculateTracks(items, sortOrder, timeOrder) {
var i, track;
sortOrder = sortOrder || "descending"; // "ascending", "descending"
timeOrder = timeOrder || "backward"; // "forward", "backward"
function sortBackward() {
// older items end deeper
items.forEach(function (item) {
for (i = 0, track = 0; i < tracks.length; i++, track++) {
if (item.end < tracks[i]) { break; }
}
item.track = track;
tracks[track] = item.start;
});
}
function sortForward() {
// younger items end deeper
items.forEach(function (item) {
for (i = 0, track = 0; i < tracks.length; i++, track++) {
if (item.start > tracks[i]) { break; }
}
item.track = track;
tracks[track] = item.end;
});
}
if (sortOrder === "ascending")
data.items.sort(compareAscending);
else
data.items.sort(compareDescending);
if (timeOrder === "forward")
sortForward();
else
sortBackward();
}
// Convert yearStrings into dates
data.items.forEach(function (item){
item.start = parseDate(item.start);
if (item.end == "") {
//console.log("1 item.start: " + item.start);
//console.log("2 item.end: " + item.end);
item.end = new Date(item.start.getTime() + instantOffset);
//console.log("3 item.end: " + item.end);
item.instant = true;
} else {
//console.log("4 item.end: " + item.end);
item.end = parseDate(item.end);
item.instant = false;
}
// The timeline never reaches into the future.
// This is an arbitrary decision.
// Comment out, if dates in the future should be allowed.
if (item.end > today) { item.end = today};
});
//calculateTracks(data.items);
// Show patterns
//calculateTracks(data.items, "ascending", "backward");
//calculateTracks(data.items, "descending", "forward");
// Show real data
calculateTracks(data.items, "descending", "backward");
//calculateTracks(data.items, "ascending", "forward");
data.nTracks = tracks.length;
data.minDate = d3.min(data.items, function (d) { return d.start; });
data.maxDate = d3.max(data.items, function (d) { return d.end; });
return timeline;
};
//----------------------------------------------------------------------
//
// band
//
timeline.band = function (bandName, sizeFactor) {
var band = {};
band.id = "band" + bandNum;
band.x = 0;
band.y = bandY;
band.w = width;
band.h = height * (sizeFactor || 1);
band.trackOffset = 4;
// Prevent tracks from getting too high
band.trackHeight = Math.min((band.h - band.trackOffset) / data.nTracks, 20);
band.itemHeight = band.trackHeight * 0.8,
band.parts = [],
band.instantWidth = 100; // arbitray value
band.xScale = d3.time.scale()
.domain([data.minDate, data.maxDate])
.range([0, band.w]);
band.yScale = function (track) {
return band.trackOffset + track * band.trackHeight;
};
band.g = chart.append("g")
.attr("id", band.id)
.attr("transform", "translate(0," + band.y + ")");
band.g.append("rect")
.attr("class", "band")
.attr("width", band.w)
.attr("height", band.h);
// Items
var items = band.g.selectAll("g")
.data(data.items)
.enter().append("svg")
.attr("y", function (d) { return band.yScale(d.track); })
.attr("height", band.itemHeight)
.attr("class", function (d) { return d.instant ? "part instant" : "part interval";});
var intervals = d3.select("#band" + bandNum).selectAll(".interval");
intervals.append("rect")
.attr("width", "100%")
.attr("height", "100%");
intervals.append("text")
.attr("class", "intervalLabel")
.attr("x", 1)
.attr("y", 10)
.text(function (d) { return d.label; });
var instants = d3.select("#band" + bandNum).selectAll(".instant");
instants.append("circle")
.attr("cx", band.itemHeight / 2)
.attr("cy", band.itemHeight / 2)
.attr("r", 5);
instants.append("text")
.attr("class", "instantLabel")
.attr("x", 15)
.attr("y", 10)
.text(function (d) { return d.label; });
band.addActions = function(actions) {
// actions - array: [[trigger, function], ...]
actions.forEach(function (action) {
items.on(action[0], action[1]);
})
};
band.redraw = function () {
items
.attr("x", function (d) { return band.xScale(d.start);})
.attr("width", function (d) {
return band.xScale(d.end) - band.xScale(d.start); });
band.parts.forEach(function(part) { part.redraw(); })
};
bands[bandName] = band;
components.push(band);
// Adjust values for next band
bandY += band.h + bandGap;
bandNum += 1;
return timeline;
};
//----------------------------------------------------------------------
//
// labels
//
timeline.labels = function (bandName) {
var band = bands[bandName],
labelWidth = 46,
labelHeight = 20,
labelTop = band.y + band.h - 10,
y = band.y + band.h + 1,
yText = 15;
var labelDefs = [
["start", "bandMinMaxLabel", 0, 4,
function(min, max) { return toYear(min); },
"Start of the selected interval", band.x + 30, labelTop],
["end", "bandMinMaxLabel", band.w - labelWidth, band.w - 4,
function(min, max) { return toYear(max); },
"End of the selected interval", band.x + band.w - 152, labelTop],
["middle", "bandMidLabel", (band.w - labelWidth) / 2, band.w / 2,
function(min, max) { return max.getUTCFullYear() - min.getUTCFullYear(); },
"Length of the selected interval", band.x + band.w / 2 - 75, labelTop]
];
var bandLabels = chart.append("g")
.attr("id", bandName + "Labels")
.attr("transform", "translate(0," + (band.y + band.h + 1) + ")")
.selectAll("#" + bandName + "Labels")
.data(labelDefs)
.enter().append("g")
.on("mouseover", function(d) {
tooltip.html(d[5])
.style("top", d[7] + "px")
.style("left", d[6] + "px")
.style("visibility", "visible");
})
.on("mouseout", function(){
tooltip.style("visibility", "hidden");
});
bandLabels.append("rect")
.attr("class", "bandLabel")
.attr("x", function(d) { return d[2];})
.attr("width", labelWidth)
.attr("height", labelHeight)
.style("opacity", 1);
var labels = bandLabels.append("text")
.attr("class", function(d) { return d[1];})
.attr("id", function(d) { return d[0];})
.attr("x", function(d) { return d[3];})
.attr("y", yText)
.attr("text-anchor", function(d) { return d[0];});
labels.redraw = function () {
var min = band.xScale.domain()[0],
max = band.xScale.domain()[1];
labels.text(function (d) { return d[4](min, max); })
};
band.parts.push(labels);
components.push(labels);
return timeline;
};
//----------------------------------------------------------------------
//
// tooltips
//
timeline.tooltips = function (bandName) {
var band = bands[bandName];
band.addActions([
// trigger, function
["mouseover", showTooltip],
["mouseout", hideTooltip]
]);
function getHtml(element, d) {
var html;
if (element.attr("class") == "interval") {
html = d.label + "
" + toYear(d.start) + " - " + toYear(d.end);
} else {
html = d.label + "
" + toYear(d.start);
}
return html;
}
function showTooltip (d) {
var x = event.pageX < band.x + band.w / 2
? event.pageX + 10
: event.pageX - 110,
y = event.pageY < band.y + band.h / 2
? event.pageY + 30
: event.pageY - 30;
tooltip
.html(getHtml(d3.select(this), d))
.style("top", y + "px")
.style("left", x + "px")
.style("visibility", "visible");
}
function hideTooltip () {
tooltip.style("visibility", "hidden");
}
return timeline;
};
//----------------------------------------------------------------------
//
// xAxis
//
timeline.xAxis = function (bandName, orientation) {
var band = bands[bandName];
var axis = d3.svg.axis()
.scale(band.xScale)
.orient(orientation || "bottom")
.tickSize(6, 0)
.tickFormat(function (d) { return toYear(d); });
var xAxis = chart.append("g")
.attr("class", "axis")
.attr("transform", "translate(0," + (band.y + band.h) + ")");
xAxis.redraw = function () {
xAxis.call(axis);
};
band.parts.push(xAxis); // for brush.redraw
components.push(xAxis); // for timeline.redraw
return timeline;
};
//----------------------------------------------------------------------
//
// brush
//
timeline.brush = function (bandName, targetNames) {
var band = bands[bandName];
var brush = d3.svg.brush()
.x(band.xScale.range([0, band.w]))
.on("brush", function() {
var domain = brush.empty()
? band.xScale.domain()
: brush.extent();
targetNames.forEach(function(d) {
bands[d].xScale.domain(domain);
bands[d].redraw();
});
});
var xBrush = band.g.append("svg")
.attr("class", "x brush")
.call(brush);
xBrush.selectAll("rect")
.attr("y", 4)
.attr("height", band.h - 4);
return timeline;
};
//----------------------------------------------------------------------
//
// redraw
//
timeline.redraw = function () {
components.forEach(function (component) {
component.redraw();
})
};
//--------------------------------------------------------------------------
//
// Utility functions
//
function parseDate(dateString) {
// 'dateString' must either conform to the ISO date format YYYY-MM-DD
// or be a full year without month and day.
// AD years may not contain letters, only digits '0'-'9'!
// Invalid AD years: '10 AD', '1234 AD', '500 CE', '300 n.Chr.'
// Valid AD years: '1', '99', '2013'
// BC years must contain letters or negative numbers!
// Valid BC years: '1 BC', '-1', '12 BCE', '10 v.Chr.', '-384'
// A dateString of '0' will be converted to '1 BC'.
// Because JavaScript can't define AD years between 0..99,
// these years require a special treatment.
var format = d3.time.format("%Y-%m-%d"),
date,
year;
date = format.parse(dateString);
if (date !== null) return date;
// BC yearStrings are not numbers!
if (isNaN(dateString)) { // Handle BC year
// Remove non-digits, convert to negative number
year = -(dateString.replace(/[^0-9]/g, ""));
} else { // Handle AD year
// Convert to positive number
year = +dateString;
}
if (year < 0 || year > 99) { // 'Normal' dates
date = new Date(year, 6, 1);
} else if (year == 0) { // Year 0 is '1 BC'
date = new Date (-1, 6, 1);
} else { // Create arbitrary year and then set the correct year
// For full years, I chose to set the date to mid year (1st of July).
date = new Date(year, 6, 1);
date.setUTCFullYear(("0000" + year).slice(-4));
}
// Finally create the date
return date;
}
function toYear(date, bcString) {
// bcString is the prefix or postfix for BC dates.
// If bcString starts with '-' (minus),
// if will be placed in front of the year.
bcString = bcString || " BC" // With blank!
var year = date.getUTCFullYear();
if (year > 0) return year.toString();
if (bcString[0] == '-') return bcString + (-year);
return (-year) + bcString;
}
return timeline;
}