Skip to content

Instantly share code, notes, and snippets.

@oluckyman
Last active December 20, 2015 21:39
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save oluckyman/6199145 to your computer and use it in GitHub Desktop.
Save oluckyman/6199145 to your computer and use it in GitHub Desktop.
Time axis with centered labels and sticky month names.

Hint: Use double click to zoom in and shift + double click to zoom out.

I was using require.js and backbone.js and therefore there is a lot of extra code here. So I will highlight the most useful parts.

There is a few features I want to share:

  • Axis labels between ticks
  • Sticky month names that remain visible at any zoom level

Labels between ticks

This feature was implemented in the axisdaysview.js.
At first it is called this.axis as usual and then text labels are moved to the half-of-the-day:

this.d3
   .call(this.axis)
   .call(this.adjustTextLabels);

Here is adjustTextLabels function:

adjustTextLabels: function(selection) {
    selection.selectAll('.major text')
        .attr('transform', 'translate(' + G.utils.daysToPixels(1) / 2 + ',0)');
}

The daysToPixels calculates the width of the day (or days) in pixels depending on the current scale. Function defined in the global.js:

daysToPixels: function(days, timeScale) {
    var d1 = new Date();
    timeScale || (timeScale = Global.timeScale);
    return timeScale(d3.time.day.offset(d1, days)) - timeScale(d1);
}

Sticky months

Sticky months are implemented in the axismonthsview.js in the renderMonthNames.
The getVisibleMonths returns an array of the months to be drawn.
The setTextPosition trying to align the name of the month to the center and applies simple maths to leave the month name at the scene as long as possible.

define([
'backbone',
'd3',
'global',
'axisdaysview',
'axismonthsview',
'zoomview',
'zoommodel',
'gridview',
], function(Backbone, d3, G,
AxisDaysView, AxisMonthsView, ZoomView, ZoomModel, GridView) {
var AppView = Backbone.View.extend({
el: '#app',
initialize: function(options) {
// init canvas
//
this.canvas = d3.select('#canvas');
this.canvas
.attr('height', G.config.height)
.attr("width", G.config.width + G.config.margin.left + G.config.margin.right);
this.canvas.select("#main")
.attr("transform", "translate(" + G.config.margin.left + "," + G.config.margin.top + ")");
// init axis canvas
//
d3.select('#axis-top')
.attr("width", G.config.width + G.config.margin.left + G.config.margin.right)
.attr("height", G.config.axis.height);
// startup modules
//
this.zoomModel = new ZoomModel();
this.axisDaysView = new AxisDaysView();
this.axisMonthsView = new AxisMonthsView();
this.zoomView = new ZoomView({model: this.model, zoomModel: this.zoomModel});
this.gridView = new GridView({model: this.model});
d3.select('header').transition().duration(500).delay(500).style('top', '0px');
G.events.trigger('initialized');
}
});
return AppView;
});
define([
'd3',
'global',
'backbone'
], function(d3, G, Backbone) {
var AxisDaysView = Backbone.View.extend({
initialize: function() {
// init axis
//
this.d3 = d3.select('#axis-top .axis.days')
.attr("transform", "translate(" + G.config.margin.left + "," + (G.config.axis.height-1) + ")");
this.axis = d3.svg.axis()
.scale(G.timeScale)
.tickSize(0,0,0)
.orient("top");
// init handlers
//
this.listenTo(G.events, 'initialized', this.render);
this.listenTo(G.events, 'zoomed', this.render);
this.listenTo(G.events, 'panned', this.adjustPan);
},
render: function() {
this.adjustTimeAxis();
this.d3
.transition().duration(G.config.duration)
.call(this.axis)
.call(this.adjustTextLabels);
}, // render
adjustTimeAxis: function() {
switch(this.getZoomState()) {
case 'longDays':
this.axis
.ticks(d3.time.days, 1)
.tickFormat(function(d) { return d3.time.format('%a %e')(d); })
.tickSubdivide(false);
break;
case 'shortDays':
this.axis
.ticks(d3.time.days, 1)
.tickFormat(function(d) { return d3.time.format('%e')(d); })
.tickSubdivide(false);
break;
case 'weeks':
this.axis
.ticks(d3.time.mondays, 1)
.tickFormat(null)
.tickSubdivide(6);
break;
default:
this.axis
.ticks(d3.time.months, 1)
.tickFormat(null)
.tickSubdivide(1);
}
}, // adjustTimeAxis
adjustTextLabels: function(selection) {
selection.selectAll('.major text')
.attr('transform', 'translate(' + G.utils.daysToPixels(1) / 2 + ',0)');
}, // adjustTextLabels
adjustPan: function() {
this.d3
.call(this.axis)
.call(this.adjustTextLabels);
}, // adjustPan
getZoomState: function() {
var delta = G.utils.daysToPixels(7);
return d3.entries(G.config.axis.ticks).filter(function(e) { return e.value <= delta; })[0].key;
} // getZoomState
});
return AxisDaysView;
});
define([
'd3',
'global',
'd3.lambdas'
], function(d3, G) {
var AxisMonthsView = Backbone.View.extend({
el: '#axis-top .axis.months',
initialize: function() {
this.d3 = d3.select(this.el)
.attr('transform', 'translate(' + G.config.margin.left + ', 0)');
this.d3.append('g')
.attr('class', 'month-names')
.attr('transform', 'translate(0, ' + (G.config.axis.height - 2) + ')');
this.listenTo(G.events, 'zoomed panned', this.render);
this.render('initial');
}, // initialize
render: function(eventType) {
this.renderMonthNames(eventType);
}, // render
renderMonthNames: function(eventType) {
var self = this,
scale1 = G.timeScale.copy(),
scale0 = this.renderMonthNames.scale || scale1,
data = this.getVisibleMonths(G.timeScale.domain()),
name = this.d3.select('.month-names').selectAll('.name').data(data, d3.ƒ('getTime')),
nameEnter, nameUpdate, nameExit,
text, textEnter, textUpdate;
this.renderMonthNames.scale = scale1;
nameEnter = name.enter();
nameUpdate = name;
nameExit = name.exit();
// ENTER
//
nameEnter
.append('text')
.attr('class', 'name')
.text(function(d) { return d3.time.format('%B')(d); })
.call(this.setTextPosition, scale0);
switch(eventType) {
case 'initial':
// set text position in the other thread
// because we need BBox of the already rendered text element
setTimeout(function() {
self.d3.select('.month-names').selectAll('.name').call(self.setTextPosition, scale0);
}, 1);
break;
case 'zoomed':
nameUpdate = nameUpdate.transition().duration(G.config.duration);
nameExit = nameExit.transition().duration(G.config.duration);
break;
}
// UPDATE
//
nameUpdate
.call(this.setTextPosition, scale1);
// EXIT
//
nameExit
.attr('opacity', 1e-6)
.call(this.setTextPosition, scale1)
.remove();
}, // renderMonthNames
setTextPosition: function(selection, scale) {
selection.each(function(d) {
var width = this.getBBox().width,
nextMonthPos = scale(d3.time.month.offset(d, 1)),
padding = 3,
minPos = 0, maxPos = scale.range()[1],
x, opacity;
x = scale(d) + G.utils.daysToPixels(15) - width / 2; // center
x = Math.max(minPos, x); // left-left
x = Math.min(x, nextMonthPos - width - padding); // left-right
x = Math.min(x, maxPos - width); // right-right
x = Math.max(x, scale(d) + padding); // right-left
if (x < minPos) {
opacity = (x + width - minPos) / width;
} else if (x + width > maxPos) {
opacity = (maxPos - x) / width;
} else {
opacity = 1;
}
d3.transition(d3.select(this))
.attr('x', x)
.attr('opacity', opacity);
});
}, // setTextPosition
// d3.time.months could not be used here because of ceil dates
// that shrink the range.
// In this function we need the extended range
//
getVisibleMonths: function(domain) {
var time = d3.time.month.floor(domain[0]),
end = d3.time.month.floor(domain[1]),
times = [ time ];
while(time < end) {
time = d3.time.month.offset(time, 1);
times.push(time);
}
return times;
} // getVisibleMonths
});
return AxisMonthsView;
});
define([
'd3'
], function(d3) {
d3.ƒ = function(name) {
var f, params = Array.prototype.slice.call(arguments, 1);
return function(d) {
f = d[name];
return typeof(f)==='function' ? f.apply(d, params) : f;
};
};
d3.I = function(d) { return d };
});
define([
'jquery',
'underscore',
'backbone',
'd3'
], function($, _, Backbone, d3) {
var Global = {
config: {},
initialize: function() {
this.initDimensions();
this.initD3();
this.initTimescale();
this.events = _.extend({}, Backbone.Events);
},
initDimensions: function() {
this.config.margin = { top: 95, right: 0, bottom: 50, left: 0 };
this.config.width = $(window).width() - this.config.margin.left - this.config.margin.right;
this.config.height = 500;
},
initD3: function() {
var self = this;
this.config.durationNormal = 300;
this.config.duration = this.config.durationNormal;
// axis
//
this.config.axis = {};
this.config.axis.height = 50;
// should to be sorted from high to low values
this.config.axis.ticks = {
longDays: 240,
shortDays: 112,
weeks: 40,
months: 0
};
},
initTimescale: function() {
// today based timescale
var today = new Date(),
magicNumber = 1.5,
days = Global.config.width / (this.config.axis.ticks.shortDays/7) / magicNumber;
this.timeScale = d3.time.scale()
.domain([d3.time.day.offset(today, -0.6 * days),
d3.time.day.offset(today, 0.4 * days)])
.rangeRound([0, Global.config.width]);
},
utils: {
daysToPixels: function(days, timeScale) {
var d1 = new Date();
timeScale || (timeScale = Global.timeScale);
return timeScale(d3.time.day.offset(d1, days)) - timeScale(d1);
} // daysToPixels
}
};
Global.initialize();
return Global;
});
define([
'd3',
'global',
'backbone'
], function(d3, G, Backbone) {
var GridView = Backbone.View.extend({
el: '#grid',
initialize: function() {
this.d3 = d3.select(this.el)
.attr("transform", "translate(" + G.config.margin.left + ",0)");
this.listenTo(G.events, 'initialized', this.initAxis);
this.listenTo(G.events, 'initialized', this.render);
this.listenTo(G.events, 'zoomed', this.render);
this.listenTo(G.events, 'panned', this.adjustPan);
},
initAxis: function() {
this.axis = d3.svg.axis().scale(G.timeScale).tickSize(0,0,0);
var height = G.config.height;
this.axis
.tickSize(height, height)
.tickFormat("");
this.d3.append('g').attr('class', 'days-ticks');
this.d3.append('g').attr('class', 'weekends');
this.d3.append('g').attr('class', 'month-ticks');
},
onResize: function() {
this.render();
},
render: function() {
this.renderGrid.apply(this, arguments);
this.renderWeekends.apply(this, arguments);
this.renderMonthTicks.apply(this, arguments);
}, // render
renderGrid: function() {
this.axis.tickSize(G.config.height);
switch(this.getZoomState()) {
case 'months':
this.axis.ticks(d3.time.months, 1);
break;
default:
this.axis.ticks(d3.time.days, 1);
break;
}
this.d3.select('.days-ticks')
.transition().duration(G.config.duration)
.call(this.axis);
}, // renderGrid
renderWeekends: function(eventType) {
var scale1 = G.timeScale.copy(),
scale0 = this.renderWeekends.scale || scale1,
domain = scale1.domain(),
weekend = this.d3.select('.weekends').selectAll('.weekend')
.data(d3.time.saturday.range(d3.time.day.offset(domain[0], -2), domain[1]), d3.ƒ('getTime')),
weekendEnter, weekendUpdate, weekendExit;
this.renderWeekends.scale = scale1;
weekendEnter = weekend.enter();
weekendUpdate = weekend;
weekendExit = weekend.exit();
// ENTER
//
weekendEnter
.append('rect')
.attr('class', 'weekend')
.attr('y', 0)
.attr('height', G.config.height)
.attr('x', function(d) { return scale0(d); })
.attr('width', G.utils.daysToPixels(2, scale0));
// UPDATE
//
if (eventType == 'zoomed') {
weekendUpdate = weekend.transition().duration(G.config.duration);
weekendExit = weekendExit.transition().duration(G.config.duration);
}
weekendUpdate
.attr('height', G.config.height)
.attr('x', function(d) { return scale1(d); })
.attr('width', G.utils.daysToPixels(2));
// EXIT
//
weekendExit
.attr('x', function(d) { return scale1(d); })
.attr('width', G.utils.daysToPixels(2))
.remove();
}, // renderWeekends
renderMonthTicks: function(eventType) {
var data = G.timeScale.ticks(d3.time.months),
tick = this.d3.select('.month-ticks').selectAll('.tick').data(data, d3.ƒ('getTime')),
tickEnter, tickUpdate, tickExit,
scale1 = G.timeScale.copy();
scale0 = this.renderMonthTicks.scale || scale1;
this.renderMonthTicks.scale = scale1;
tickEnter = tick.enter().append('line');
tickUpdate = tick;
tickExit = tick.exit();
if (eventType == 'zoomed') {
tickUpdate = tick.transition().duration(G.config.duration);
tickExit = tickExit.transition().duration(G.config.duration);
}
// ENTER
//
tickEnter
.attr('class', 'tick')
.attr('y2', G.config.height)
.attr('transform', function(d) { return 'translate(' + scale0(d) + ', 0)'; });
// UPDATE
//
tickUpdate
.attr('transform', function(d) { return 'translate(' + scale1(d) + ', 0)'; })
.attr('y2', G.config.height);
// EXIT
//
tickExit
.attr('transform', function(d) { return 'translate(' + scale1(d) + ', 0)'; })
.remove();
}, // renderMonthTicks
adjustPan: function() {
this.d3.select('.days-ticks').call(this.axis);
this.renderWeekends.apply(this, arguments);
this.renderMonthTicks.apply(this, arguments);
}, // adjustPan
getZoomState: function() {
var delta = G.utils.daysToPixels(7);
return d3.entries(G.config.axis.ticks).filter(function(e) { return e.value <= delta; })[0].key;
} // getZoomState
});
return GridView;
});
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="main.css">
<script data-main="main" src="http://cdnjs.cloudflare.com/ajax/libs/require.js/2.1.8/require.min.js"></script>
</head>
<body>
<section id="app">
<header>
<svg id="axis-top">
<g class="axis months"></g>
<g class="axis days"></g>
</svg>
</header>
<svg id="canvas">
<g id="grid"></g>
<rect class="background"/>
</svg>
</section>
</body>
</html>
html { font-family: sans-serif; }
body { margin: 0; }
#app header {
position: fixed;
width: 100%;
box-shadow: 0 0 15px 0 #777777;
/* Firefox 3.6+ */
background-image: -moz-linear-gradient(#eeeeee, rgba(238, 238, 238, 0.5));
/* Safari 4+, Chrome 1+ */
background-image: -webkit-gradient(linear, 0% 0%, 0% 100%, from(#eeeeee), to(rgba(238, 238, 238, 0.5)));
/* Safari 5.1+, Chrome 10+ */
background-image: -webkit-linear-gradient(#eeeeee, rgba(238, 238, 238, 0.5));
/* Opera 11.10+ */
background-image: -o-linear-gradient(#eeeeee, rgba(238, 238, 238, 0.5));
height: 50px;
top: -50px; }
#app header #axis-top {
position: absolute;
left: 0;
bottom: 0; }
#app .background {
fill: none;
cursor: col-resize;
pointer-events: all; }
#app #grid path,
#app #grid line {
fill: none;
stroke: none;
shape-rendering: crispEdges; }
#app #grid .tick.major line {
stroke: #eee; }
#app #grid .tick.minor {
stroke: #eee; }
#app #grid .weekend {
fill: #eee;
fill-opacity: 0.5; }
#app #grid .month-ticks {
opacity: 0.5; }
#app #grid .month-ticks .tick {
stroke: #000; }
#app .axis path,
#app .axis line {
fill: none;
stroke: black;
shape-rendering: crispEdges; }
#app .axis .domain {
display: none; }
#app .axis.days text {
transition: font-size 0.15s;
font-size: 11px;
fill: #333;
text-shadow: 0px 1px 1px white; }
#app .axis.months .month-names {
opacity: 0.1; }
#app .axis.months .month-names .name {
font-size: 60px;
font-variant: small-caps;
font-weight: bold; }
#app .axis.months .month-ticks {
opacity: 0.5; }
require.config({
paths: {
"jquery": 'http://ajax.googleapis.com/ajax/libs/jquery/2.0.0/jquery.min',
"underscore": 'http://cdnjs.cloudflare.com/ajax/libs/underscore.js/1.5.1/underscore-min',
"backbone": 'http://cdnjs.cloudflare.com/ajax/libs/backbone.js/1.0.0/backbone-min',
"d3": 'http://cdnjs.cloudflare.com/ajax/libs/d3/3.1.6/d3.min',
"d3.lambdas": 'd3.lambdas'
},
shim: {
"underscore": { exports: '_' },
"backbone": { deps: ["underscore", "jquery"], exports: "Backbone" },
"d3": { exports: 'd3' },
"d3.lambdas": [ 'd3' ]
}
});
require(['appview'], function(AppView) {
new AppView();
});
define([
'backbone',
'd3',
'global'
], function(Backbone, d3, G) {
var ZoomModel = Backbone.Model.extend({
initialize: function() {
this.zoomBeh = d3.behavior.zoom().x(G.timeScale);
},
d3_helpers: function() {
var zoom = this.zoomBeh,
scale = zoom.scale(),
scaleExtent = zoom.scaleExtent(),
translate = zoom.translate();
function point(l) { return [l[0] * scale + translate[0], l[1] * scale + translate[1]]; }
return {
location: function(p) { return [(p[0] - translate[0]) / scale, (p[1] - translate[1]) / scale]; },
scaleTo: function(s) { scale = Math.max(scaleExtent[0], Math.min(scaleExtent[1], s)); },
translateTo: function(p, l) { l = point(l); translate[0] += p[0] - l[0]; translate[1] += p[1] - l[1]; },
applyZoom: function() { zoom.scale(scale); }
};
},
zoom: function(el) {
var h = this.d3_helpers(),
p = d3.mouse(el),
l = h.location(p),
k = Math.log(this.zoomBeh.scale()) / Math.LN2;
h.scaleTo(Math.pow(2, d3.event.shiftKey ? Math.ceil(k) - 1 : Math.floor(k) + 1));
h.translateTo(p, l);
h.applyZoom();
G.events.trigger('zoomed', 'zoomed');
}, // zoom
startPan: function(target) {
var self = this,
h = this.d3_helpers(),
l = h.location(d3.mouse(target));
this.moved = false;
// worker will be called while mouse move
return function () {
self.moved = true;
h.translateTo(d3.mouse(target), l);
h.applyZoom();
G.events.trigger('panned', 'panned');
};
}
});
return ZoomModel;
});
define([
'backbone',
'd3',
'global'
], function(Backbone, d3, G) {
var ZoomView = Backbone.View.extend({
el: '#canvas .background',
initialize: function(options) {
var self = this;
this.zoomModel = options.zoomModel;
// init background rect
//
this.d3 = d3.select(this.el)
.attr("width", G.config.width + G.config.margin.left + G.config.margin.right)
.attr("height", G.config.height + G.config.margin.top + G.config.margin.bottom)
.on("dblclick", function() { self.zoomModel.zoom(d3.select('#canvas .background').node()); })
.on("mousedown", function() { self.mousedown(); } );
},
// start panning
//
mousedown: function() {
if (d3.event.button !== 0) return;
var zoomModel = this.zoomModel,
panWorker = this.zoomModel.startPan(d3.event.target),
w = d3.select(window)
.on('mousemove.pan', function mousemove() { panWorker(); })
.on('mouseup.pan', function mouseup() {
if (zoomModel.moved) {
d3.event.preventDefault();
}
w.on("mousemove.pan", null).on("mouseup.pan", null);
});
d3.event.preventDefault();
window.focus();
} // mousedown
});
return ZoomView;
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment