registry-status-pagelet
Version:
Pagelet with visual status overview of npm registries
944 lines (834 loc) • 24.8 kB
JavaScript
/*global d3,topojson,pipe*/
'use strict';
/**
* Construct a new geographical map.
*
* @constructor
* @param {Object} data World map data.
* @param {Object} dispatch Event handler.
* @api public
*/
function Map(data, dispatch) {
this.projection = d3.geo.mercator();
this.path = d3.geo.path();
this.dispatch = dispatch;
this.options = data.options || {};
this.data = data;
}
/**
* Initialize the map on the provided element.
*
* @param {Element} base SVG element holding the map.
* @param {Function} set add properties to SVG element.
* @returns {Map} fluent interface
* @api public
*/
Map.prototype.initialize = function initialize(base, set) {
this.container = set(
base.append('svg').attr('class', 'map'),
this.options.width * this.options.ratio,
this.options.height
);
this.path = this.path.projection(this.projection);
this.projection = this.projection.scale(this.options.scale);
this.draw();
return this;
};
/**
* Draw paths on the map under a svg group element.
*
* @return {Map} fluent interface
* @api public
*/
Map.prototype.draw = function draw() {
var world = this.container.append('g').attr('class', 'countries'),
colors = this.colors;
//
// Draw the world.
//
world.selectAll('path')
.data(this.data.world.features)
.enter()
.append('path')
.attr('d', this.path)
.attr('class', function (d) {
return [
d.properties.name
.toLowerCase()
.replace(/\s/g, '-'),
d.properties.continent
.toLowerCase()
.replace(' ', '-')
].join(' ');
});
//
// Update the position of the drawn map relative to the containing SVG element.
//
this.projection = this.projection.translate([
(world[0][0].getBoundingClientRect().width / 2) - 75,
this.options.height / 1.6 // pretty arbitrary value, exact ratio unknown?
]);
//
// Redraw paths of all countries based on the translated map.
//
world.selectAll('path').data(this.data.world.features).attr('d', this.path);
return this;
};
/**
* Construct new registries in the map.
*
* @constructor
* @param {Object} data World map data.
* @param {Object} dispatch Event handler.
* @param {Map} map
* @api public
*/
function Registries(data, dispatch, map) {
this.map = map;
this.dispatch = dispatch;
this.options = data.options || {};
this.data = data;
this.locations = map.container.append('g').attr('class', 'registries');
}
/**
* Add registry location markers to the map.
*
* @param {Object} data
* @param {String} marker SVG path
* @api public
*/
Registries.prototype.add = function add(data, marker) {
this.locations
.selectAll('path')
.data(data)
.enter()
.append('path')
.attr('class', this.id)
.attr('d', marker)
.attr('transform', this.translate.bind(this));
return this;
};
/**
* Return the IDs of all registries at the current location.
*
* @param {Object} datum Registry data.
* @returns {String} Registry IDs joined by commas.
* @api public
*/
Registries.prototype.id = function id(datum) {
return datum.registries.map(function (registry) {
return registry.name;
}).join(' ');
};
/**
* Highlight the selected registry.
*
* @param {String} className
* @api public
*/
Registries.prototype.highlight = function highlight(className) {
this.locations.selectAll('path').classed('highlight', false);
this.locations.selectAll('path.'+ className).classed('highlight', true);
};
/**
* Translate the marker to calculated map position.
*
* @param {Object} registry
* @api public
*/
Registries.prototype.translate = function translate(registry) {
var xy = this.map.projection(registry.lonlat);
xy[0] = xy[0] - 21/2;
xy[1] = xy[1] - 26; // TODO: use actual height/width of marker
return 'translate('+ xy.join() +')';
};
/**
* Add charts to the pagelet.
*
* @constructor
* @param {Object} data World map data.
* @param {Object} dispatch Event handler.
* @api public
*/
function Charts(data, dispatch) {
data.options = data.options || {};
data.options.width = data.options.width * (1 - data.options.ratio);
this.data = data;
this.options = data.options;
this.dispatch = dispatch;
this.stack = {};
}
/**
* Initialize the map on the provided element.
*
* @param {Element} base SVG element holding the charts.
* @param {Function} set add properties to SVG element.
* @return {Chart} fluent interface
*/
Charts.prototype.initialize = function initialize(base, transform) {
var groups = {};
//
// Create new SVG element for all charts, the width is adjusting for border width
// or any cross browser inconsistencies.
//
this.container = transform(
base.append('svg').attr('class', 'charts'),
this.options.width - 10,
this.options.height
);
//
// Create groups per per registry and add charts per data type.
//
for (var type in this.data.status) {
for (var registry in this.data.status[type]) {
if (!(registry in groups)) {
groups[registry] = this.container.append('g').attr({
class: 'registry ' + registry,
transform: 'translate(0,' + this.options.margin.top + ')'
});
}
this.add(
groups[registry],
this.name(type, registry),
this.data.status[type][registry],
this.data.latest[type][registry],
this.options[type]
);
}
}
return this;
};
/**
* Handle selection of npm registry. Hide charts and data but the selected registry.
*
* @param {String} id Registry ID matching the className of the chart group.
* @api public
*/
Charts.prototype.select = function select(id) {
var duration = this.options.animation / 4;
//
// Return if the current registry is already displayed.
//
if (this.container.select('.show.' + id)[0][0]) return;
//
// Hide current registry charts.
//
this.container.selectAll('.registry.show')
.transition()
.duration(duration)
.attr('transform', 'translate('+ this.options.width +','+ this.options.margin.top +')')
.each('end', function (element) {
d3.select(this).classed('show', false);
});
//
// Show the new graphs by moving in from the left
//
this.container.select('.' + id)
.classed('show', true)
.attr('transform', 'translate('+ -this.options.width +','+ this.options.margin.top +')')
.transition()
.duration(duration)
.attr('transform', 'translate(0,'+ this.options.margin.top +')');
};
/**
* Add a new chart to the collection. The dimensions, placement and some options
* will be preconfigured for a nice visual layout.
*
* @param {Element} group
* @param {String} name Unique identifier.
* @param {Array} data Collection of data object.
* @param {Object} latest Most recent measurement for the chart, displayed left.
* @param {Object} options
* @api public
*/
Charts.prototype.add = function add(base, name, data, latest, options) {
var container = base.append('g').attr('class', 'type ' + name)
, elements = base.selectAll('.type')[0]
, margin = this.options.margin
, width = this.options.width - margin.left - margin.right
, height = Math.round(this.options.height / 5)
, vertical = (elements.length - 1) * (height + margin.bottom * 2)
, translate = [ this.options.margin.left, vertical ].join();
//
// Translate the graph to the proper place and define a drawable area on
// the chart defined by clip path.
//
container.attr('transform', 'translate('+ translate +')');
//
// Add some options that are forced from the charts SVG container.
//
options = options || {};
options.height = height;
options.width = width;
options.ratio = this.options.ratio;
options.animation = this.options.animation;
//
// Initialize the chart and add it to the stack for reference.
//
this.stack[name] = new Chart(name, container, data, latest, options);
};
/**
* Create unique identifier for a chart based on data type and registry name.
*
* @param {String} type Data type, per example ping.
* @param {String} registry Name of the registry, per example Nodejitsu.
* @return {String} unique identifier
* @api public
*/
Charts.prototype.name = function name(type, registry) {
return type + ':' + registry;
};
/**
* Append data to the chart identified by probe.
*
* @param {Object} probe Specifications for the chart and data.
* @api private
*/
Charts.prototype.append = function append(probe) {
var name = this.name(probe.data.name, probe.data.registry);
this.stack[name].update(probe);
};
/**
* Create a new chart instance.
*
* @constructor
* @param {String} name Unique identifier.
* @param {Element} SVG element that holds the chart's elements
* @param {Array} data Collection of data points.
* @param {Object} latest Most recent measurement for the chart, displayed left.
* @param {Object} options
* @api public
*/
function Chart(name, container, data, latest, options) {
var chart = this
, i;
this.container = container;
this.options = options = options || {};
this.latest = latest;
this.data = [];
this.name = name;
this.step = options.step || 6E4; // Step size in milliseconds, e.g. 1 minute.
this.n = options.n || 120; // Steps, e.g. 2 hours.
this.key = options.key || 'mean'; // Data key used for displaying data.
//
// Reference to the tooltip, when the tooltip itself is clicked hide it.
//
this.tip = d3.select('.tooltip').on('click', this.tooltip.bind(this, '', true));
//
// Get the max value for the current domain.
//
this.max = function max(d) { return d.values[chart.key]; };
//
// Select the end of the data range equal to the number of steps.
//
i = this.n < data.length ? this.n : data.length + 1;
while (--i) { this.data.push(data[data.length - i]); }
//
// Construct all parts of the chart.
//
this.statistics();
this.visuals();
//
// Initialize animation loop.
//
this.animate(options.animation);
}
/**
* Add statistics container to the chart. This contains the metric's name, unit
* and most recent value.
*
* @api private
*/
Chart.prototype.statistics = function statistics() {
this.stats = this.container.append('g').attr('class', 'stats').attr(
'transform',
'translate(0,10)'
);
//
// Add a title and unit.
//
this.text(this.stats, this.options.title, 'title', [120, 0]);
this.text(this.stats, this.options.unit, 'unit', [120, 60]);
//
// Display the most recent data left of the chart, text is aligned to the right.
//
this.last = this.text(this.stats, 0, 'value', [120, 40]);
this.current(
this.last,
this.latest,
this.options.animation
);
};
/**
* Create the actual chart graphics.
*
* @api private
*/
Chart.prototype.visuals = function visuals() {
var visual = this.options.visual in this ? this.options.visual : 'line'
, options = {
width: Math.round(this.options.width * this.options.ratio),
height: this.options.height,
x: this.options.x || {},
y: this.options.y || {}
};
//
// Transform the chart to the right so there is room for the statistics.
//
this.chart = this.container.append('g').attr('class', 'chart').attr(
'transform',
'translate('+ this.options.width * (1 - this.options.ratio) +',0)'
);
//
// Define rectangular clipping area that will hide the path.
//
this.chart.append('defs').append('clipPath').attr('id', this.name).append('rect')
.attr('width', options.width - 1)
.attr('height', options.height - 1);
//
// Add the axes and data series.
//
this.x = this.time(this.chart, options);
this.y = this.units(this.chart, options);
//
// Add grid lines if required and add the actual data series.
//
if (options.y.grid) this.grid(this.chart, options);
if (options.x.grid) this.grid(this.chart, options, true);
//
// Add horizontal grid lines.
//
this.serie = this[visual](this.chart, options);
};
/**
* Append text to the chart, the element will be updated if the class is
* not unique within the current container. Although IDs seem more
* appropriate in this case CSS can easily be applied to all classes.
*
* @api private
*/
Chart.prototype.text = function text(base, value, className, translate, left) {
var sel = base.select('.' + className);
//
// Create text element if it does not exist and translate if provided.
//
if (sel.empty()) sel = base.append('text').attr('class', className);
if (translate) sel.attr('transform', 'translate('+ translate.join() +')');
if (!left) sel.attr('text-anchor', 'end');
//
// Update the content of the element.
//
return sel.text(value);
};
/**
* Do a fancy update of the most recent metric. Tween will ensure that the numbers
* are visually incremented or decremented via interpolate.
*
* @param {Number} value Update to this number.
* @param {Number} duration Animation duration.
* @api public
*/
Chart.prototype.current = function current(base, value, duration) {
//
// Current or new value is not a number.
//
if ('number' !== typeof value || isNaN(+base.text())) {
return base.text('number' === typeof value ? Math.round(value) : value);
}
base.transition().duration(duration). tween('text', function tween() {
var i = d3.interpolate(this.textContent, Math.round(value));
return function loop(t) {
this.textContent = Math.round(i(t));
};
});
};
/**
* Add horizontal x-axis to the chart.
*
* @param {Element} base container for the axis.
* @param {Object} options
* @return {Object} reference to constructed parts of the axis.
* @api public
*/
Chart.prototype.time = function time(base, options) {
var axis = d3.svg.axis().orient('bottom')
, container = base.append('g').attr('class', 'x axis')
, scale = this.scale(options.x.type, 'x', [0, options.width], options);
//
// Translate the axis down with the height of the chart.
//
container.attr('transform', 'translate(0,'+ options.height +')');
//
// Add specified format to the axis.
//
if ('format' in options.x) axis.tickFormat(d3.time.format(options.x.format));
return {
scale: scale,
axis: axis.scale(scale).ticks(options.x.ticks),
container: container.call(axis)
};
};
/**
* Add grid lines to the chart.
*
* @param {Element} base Chart SVG group
* @param {Object} options
* @param {Boolean} vertical Type of grid lines.
* @api private
*/
Chart.prototype.grid = function grid(base, options, vertical) {
var chart = this
, axis = vertical ? 'x' : 'y';
/**
* Helper function to find the reference point in pixels on the axis.
*
* @param {Number} d Data point
* @return {Number} location in pixels
*/
function line(d) {
return Math.round(chart[axis].scale(d));
}
//
// Add the lines.
//
base
.selectAll('.grid.' + axis)
.data(chart[axis].scale.ticks(options[axis].ticks))
.enter()
.append('line')
.attr({
class: 'grid ' + axis,
x1: vertical ? line : 0,
x2: vertical ? line : options.width,
y1: vertical ? 0 : line,
y2: vertical ? options.height: line
});
};
/**
* Add vertical y-axis to the chart.
*
* @param {Element} base container for the axis.
* @param {Object} options
* @return {Object} reference to constructed parts of the axis.
* @api public
*/
Chart.prototype.units = function units(base, options) {
var axis = d3.svg.axis().orient('right')
, container = base.append('g').attr('class', 'y axis')
, scale = this.scale(options.y.type, 'y', [options.height, 0], options);
//
// Translate the axis to the right of the chart.
//
container.attr('transform', 'translate('+ options.width +',0)');
return {
scale: scale,
axis: axis.scale(scale).ticks(this.options.y.ticks),
container: container.call(axis)
};
};
/**
* Helper method to create a scale with correct properties.
*
* @param {String} type Scale to construct
* @param {String} dimension X or Y
* @param {Array} range Lower and upper limits of the scale
* @param {Object} options
* @return {Object} d3 scale
* @api private
*/
Chart.prototype.scale = function scale(type, dimension, range, options) {
var domain = options[dimension].domain
, construct;
if (!domain) {
switch (dimension) {
case 'x': domain = this.range(Date.now(), this.n, this.step, true); break;
case 'y': domain = [0, d3.max(this.data, this.max)]; break;
}
}
switch (type) {
case 'ordinal':
construct = d3.scale.ordinal().rangeRoundBands(range);
break;
case 'time':
construct = d3.time.scale().range(range);
break;
default:
construct = d3.scale.linear().range(range);
break;
}
return construct.domain(domain);
};
/**
* Create backwards domain based on end and interval repeated n times.
*
* @param {Number} end Last number in sequence
* @param {Number} n Number of steps.
* @param {Number} interval Size of each step.
* @param {Boolean} boundary Only return range boundaries.
* @returns {Array} Range
* @api private
*/
Chart.prototype.range = function range(end, n, interval, boundary) {
if (boundary) return [end - n * interval, end];
var result = [];
while (n--) {
result.unshift(end - n * interval);
}
return result;
};
/**
* Add line with basic interpolation to the chart.
*
* @param {Element} base Container for the line.
* @param {Object} options
* @return {Object} reference to constructed parts of the axis.
* @api public
*/
Chart.prototype.line = function line(base, options) {
var container = base.append('g').attr('clip-path', 'url(#'+ this.name +')')
, visual = container.append('path').data([ this.data ]).attr('class', 'line')
, serie = d3.svg.line().interpolate('basis')
, chart = this;
serie.x(function serieX(d, i) {
return chart.x.scale(d.t);
});
serie.y(function serieY(d) {
return chart.y.scale(d.values[chart.key]);
});
return {
stack: serie,
container: visual,
animate: function animate(duration) {
var domain = chart.range(Date.now(), chart.n, chart.step, true);
//
// Animate the time axis.
//
chart.x.scale.domain(domain);
chart.x.container
.transition()
.duration(duration)
.ease('linear')
.call(chart.x.axis);
//
// Update the unit axis.
//
chart.y.scale.domain([0, d3.max(chart.data, chart.max)]);
chart.y.container
.transition()
.duration(duration)
.ease('linear')
.call(chart.y.axis);
//
// Draw the line and transition to the left.
//
chart.serie.container.attr('d', chart.serie.stack).attr('transform', null);
chart.serie.container
.transition()
.duration(duration)
.ease('linear')
.attr('transform', 'translate(' + chart.x.scale(domain[0]) +',0)');
}
};
};
/**
* Generate stacked bars, values.lower will be used to calculate the start of
* the next stacked bar.
*
* @param {Element} base Container for the line.
* @param {Object} options
* @return {Object} reference to constructed parts of the axis.
*/
Chart.prototype.bar = function bar(base, options) {
var container = base.append('g').attr('clip-path', 'url(#'+ this.name +')')
, width = Math.round(options.width / this.options.x.ticks) - 1
, chart = this;
function height(point) {
return options.height - chart.y.scale(point);
}
//
// Add all the bars to the chart.
//
var serie = container
.selectAll('.stack')
.data(this.data)
.enter()
.append('rect')
.attr('width', width)
.attr('x', function (d) {
return Math.round(chart.x.scale(d.t));
})
.attr('y', function (d) {
return Math.round(chart.y.scale(d.values[chart.key]) - height(d.values.lower));
})
.attr('height', function (d) {
return Math.round(height(d.values[chart.key]));
})
.attr('class', function (d) {
return 'stack ' + d.values.type;
});
return {
stack: serie,
container: container
};
};
/**
* Add heatmap to the chart.
*
* @param {Element} base Container for the line.
* @param {Object} options
* @return {Object} reference to constructed parts of the axis.
* @api public
*/
Chart.prototype.heatmap = function heatmap(base, options) {
var container = base.append('g').attr('clip-path', 'url(#'+ this.name +')')
, width = Math.round(options.width / this.options.x.ticks)
, height = options.height / this.options.y.ticks
, chart = this;
//
// Add rectangle per data point.
//
var serie = container
.selectAll('.unit')
.data(this.data)
.enter()
.append('rect')
.attr('rx', 2)
.attr('ry', 2)
.attr('width', width)
.attr('height', height)
.attr('class', function (d) {
var value = Math.round(d.values.n / 10);
return 'heatmap hecta-' + (value < 10 ? value : 10);
})
.attr('x', function (d) {
return Math.round(chart.x.scale(d.t));
})
.attr('y', function (d) {
return Math.round(chart.y.scale(d.values.type));
});
return {
stack: serie,
container: container
};
};
/**
* Update the chart data and trigger animations on the chart.
*
* @param {Object} stack metrics to append to the data.
* @api public
*/
Chart.prototype.update = function update(stack) {
this.data.push(stack.data.results);
//
// Update the axes and last shown metric.
//
this.current(this.last, stack.latest, this.options.animation);
this.animate(this.options.animation);
//
// Pop oldest metric of the stack.
//
this.data.shift();
};
/**
* Animate the whole chart for the provided duration.
*
* @api private
*/
Chart.prototype.animate = function animate(duration) {
if ('animate' in this.serie) this.serie.animate(duration);
};
/**
* Register event listener for registry selection. This will dispatch an event
* that will be listened to from the charts collection.
*
* @param {Object} registry Reference to self
* @return {Function} listener.
* @api public
*/
Chart.prototype.tooltip = function tooltip(content, hide) {
var duration = this.options.animation / 2
, position = d3.mouse(d3.select('.svg')[0][0])
, names;
//
// Determine if the tooltip is currently visible.
//
hide = hide || this.tip[0][0].offsetParent;
//
// Hide the tooltip to start fresh.
//
this.tip.transition().duration(duration / 2).style({
opacity: 0,
display: 'none'
});
if (hide) return;
//
// Replace innerHTML of the tooltip with all registry names.
//
this.tip.html(content).style({
left: position[0] + 60 + 'px',
top: position[1] + 80 + 'px',
display: 'block'
});
//
// Fade in and add/replace listeners on the tooltip.
//
this.tip.transition().duration(duration).style('opacity', 1);
};
//
// Initialize the map from the data and options.
//
pipe.once('status:initialize', function init(pagelet) {
var hash = window.location.hash
, dispatch = d3.dispatch('select')
, holder = d3.select(pagelet.placeholders[0]).select('.row .svg')
, map = new Map(pagelet.data, dispatch).initialize(holder, transform)
, charts = new Charts(pagelet.data, dispatch).initialize(holder, transform)
, registries = new Registries(pagelet.data, dispatch, map).add(
pagelet.data.registries,
pagelet.data.marker
);
//
// If a specific location is selected update the charts, on receiving new
// data append the latest metric to the charts.
//
pagelet.pipe.stream.on('data', charts.append.bind(charts));
d3.selectAll('.registries li').on('click', change);
//
// Show npmjs.org main registry by default, yes I know not
// even the Nodejitsu mirror first, weird huh ;)
//
change(!hash ? 'npmjs' : hash.slice(1));
/**
* Transform an SVG element and set visual attributes.
*
* @param {Element} element SVG element.
* @param {Number} width
* @param {Number} height
* @api private
*/
function transform(element, width, height) {
element
.attr('width', width)
.attr('height', height)
.attr('viewBox', [0, 0, width, height].join(' '))
.attr('preserveAspectRatio', 'xMinYMin meet');
return element;
}
/**
* Handle selection of registries.
*
* @param {String} id Unique identifier of the registry.
* @api private
*/
function change(id) {
id = id || this.id;
charts.select(id);
registries.highlight(id);
//
// Highlight selected registry.
//
if (d3.event) d3.event.preventDefault();
d3.selectAll('.registries li').classed('highlight', false);
d3.select('#' + id).classed('highlight', true);
}
});