/* globals window */ /** * A collection of components that make it easy to build interactive financial charts with D3 * * @namespace fc */ window.fc = { version: '0.0.0', /** * Studies, trend-lines and other financial indicators that can be added to a chart * * @namespace fc.indicators */ indicators: {}, math: {}, /** * Useful complex scales which add to the D3 scales in terms of render quality. * Also, complex financial scales that can be added to a chart * * @namespace fc.scale */ scale: { discontinuity: {} }, series: {}, /** * Interaction components include events and user options to change views * Features such as Zoom, Panning * @namespace fc.interaction */ interaction: {}, tools: {}, /** * Utility components to shorted long winded implementations of common operations. * Also includes components for mock data generation and layout. * * @namespace fc.utilities */ utilities: {} }; (function(d3, fc) { 'use strict'; /** * The chart builder makes it easier to constructs charts from a number of 'fc' or D3 components. It * adapts a chartLayout (which is responsible for creating a suitable SVG structure for a chart), and allows * you to associate components (axes, series, etc ...) with the chart. The chart builder * is responsible for associating data with the components, setting the ranges of the scales and updating * the components when the chart needs to be re-drawn. * * @type {object} * @memberof fc.utilities * @class fc.utilities.chartBuilder */ fc.utilities.chartBuilder = function(chartLayout) { // the components that have been added to the chart. var plotAreaComponents = []; var axes = {}; // the selection that this chart is associated with var callingSelection; var chartBuilder = function(selection) { callingSelection = selection; selection.call(chartLayout); }; /** * Adds a number of components to the chart plot area. The chart layout is responsible for * rendering these components via the render function. * * @memberof fc.utilities.chartBuilder# * @method addToPlotArea * @param {array} components an array of components to add to the plot area */ chartBuilder.addToPlotArea = function(components) { plotAreaComponents = plotAreaComponents.concat(components); }; /** * Provides the data that will be joined with the plot area selection, and as a result * is the data used by components that are associated with the plot area. * * @memberof fc.utilities.chartBuilder# * @method setData * @param {array} data the data to associate with the plot area */ chartBuilder.setData = function(data) { chartLayout.getPlotArea().datum(data); }; /** * Sets the chart axis with the given orientation. The chart builder is responsible for setting * the range of this axis and rendering it via the render function. * * @memberof fc.utilities.chartBuilder# * @method setAxis * @param {string} orientation The orientation of the axis container * @param {object} axis a D3 or 'fc' axis component */ chartBuilder.setAxis = function(orientation, axis) { axes[orientation] = axis; }; /** * Renders all of the components associated with this chart. During the render process * the axes have their scales set to an appropriate value. * * @memberof fc.utilities.chartBuilder# * @method render */ chartBuilder.render = function() { callingSelection.call(chartLayout); // call each of the axis components with the axis selection for (var axisOrientation in axes) { if (axes.hasOwnProperty(axisOrientation)) { var axisContainer = chartLayout.getAxisContainer(axisOrientation); var axis = axes[axisOrientation]; if (axisOrientation === 'top' || axisOrientation === 'bottom') { axis.scale().range([0, chartLayout.getPlotAreaWidth()]); } else { axis.scale().range([chartLayout.getPlotAreaHeight(), 0]); } axisContainer.call(axis); } } // call each of the plot area components plotAreaComponents.forEach(function(component) { chartLayout.getPlotArea().call(component); }); }; return chartBuilder; }; }(d3, fc)); (function(d3, fc) { 'use strict'; /** * Based on the [Margin Convention]{@link http://bl.ocks.org/mbostock/3019563}, * the Chart Layout component is responsible for defining the chart area. * * It attempts to simplify the repetitive process of constructing the chart's layout and its associated elements: *
* <div id="myChart" style="width:650px; height:300px;"></div> ** * Chart Layout will tranform the selection to create the following elements: *
* <div id="myChart" style="width:650px; height:300px;"> * <svg width="650" height="300"> * <g class="chartArea" transform="translate(40,20)"> * <defs> * <clipPath id="fcPlotAreaClip_myChart"> * <rect width="570" height="260"></rect> * </clipPath> * </defs> * <rect class="background" width="570" height="260"></rect> * <g clip-path="url(#fcPlotAreaClip_myChart)" class="plotArea"></g> * <g class="axis bottom" transform="translate(0,260)"></g> * <g class="axis top" transform="translate(0, 0)"></g> * <g class="axis right" transform="translate(570, 0)"></g> * </g> * </svg> * </div> ** * @type {object} * @memberof fc.utilities * @class fc.utilities.chartLayout */ fc.utilities.chartLayout = function() { // Default values var margin = {top: 20, right: 40, bottom: 20, left: 40}, width = 0, height = 0; var defaultWidth = true, defaultHeight = true; // The elements created for the chart var chartElements = {}; var plotAreaClipId; /** * Constructs a new instance of the chartLayout component. * * Applies the chartLayout to a [D3 selection]{@link https://github.com/mbostock/d3/wiki/Selections} * (commonly a
div
).
* The chartLayout component can only be applied to the first element in a selection,
* all other elements will be ignored.
*
* @example
* // Setup the chart layout
* var layout = fc.utilities.chartLayout();
*
* // Setup the chart
* var setupArea = d3.select('#chart')
* .call(layout);
*
* @memberof fc.utilities.chartLayout#
* @method chartLayout
* @param {selection} selection a D3 selection
*/
var chartLayout = function(selection) {
// Select the first element in the selection
// If the selection contains more than 1 element,
// only the first will be used, the others will be ignored
var element = selection.node(),
style = getComputedStyle(element);
// Attempt to automatically size the chart to the selected element
if (defaultWidth === true) {
// Set the width of the chart to the width of the selected element,
// excluding any margins, padding or borders
var paddingWidth = parseInt(style.paddingLeft, 10) + parseInt(style.paddingRight, 10);
width = element.clientWidth - paddingWidth;
// If the new width is too small, use a default width
if (chartLayout.getPlotAreaWidth() < 1) {
width = 600 + margin.left + margin.right;
}
}
if (defaultHeight === true) {
// Set the height of the chart to the height of the selected element,
// excluding any margins, padding or borders
var paddingHeight = parseInt(style.paddingTop, 10) + parseInt(style.paddingBottom, 10);
height = element.clientHeight - paddingHeight;
// If the new height is too small, use a default height
if (chartLayout.getPlotAreaHeight() < 1) {
height = 400 + margin.top + margin.bottom;
}
}
// Setup the elements - following the general update pattern (http://bl.ocks.org/mbostock/3808218)
//
// When creating the elements for the chart, only one of each element is required. To achieve this we bind
// a single datum to each selection - this is represented in the dummyData variable. This data-join is only
// used for creating and updating the elements - through data(), enter() and exit(); the value of the data
// is irrelevant (but there must only be one value). This approach is similar to that used in D3's axis
// and brush components.
//
// For each element, we:
// 1. Select the element(s) and bind a single datum to that selection
// 2. If no element currently exists, append it (this is in the enter() subselection)
// 3. Update the element as required
// 4. If there are too many of the selected element(>1), then remove it (this is in the exit() subselection)
var container = d3.select(element),
dummyData = [0];
// Create svg
var svg = container.selectAll('svg').data(dummyData);
svg.enter().append('svg');
svg.attr('width', width)
.attr('height', height)
.style('display', 'block');
svg.exit().remove();
// Create group for the chart
function roundToNearestHalfInteger(n) {
var m = Math.round(n);
return n > m ? m + 0.5 : m - 0.5;
}
var chart = svg.selectAll('g.chartArea').data(dummyData);
chart.enter().append('g')
.classed('chartArea', true);
chart.attr('transform', 'translate(' +
roundToNearestHalfInteger(margin.left) + ',' +
roundToNearestHalfInteger(margin.top) + ')');
chart.exit().remove();
// Create defs - for clipping path
var defs = chart.selectAll('defs').data(dummyData);
defs.enter().append('defs');
defs.exit().remove();
// Get an ID for the clipping path
// If the element already has an ID, use that; otherwise, generate one (to avoid duplicate IDs)
plotAreaClipId = plotAreaClipId || 'fcPlotAreaClip_' + (element.id || nextId());
// Clipping path
var clippingPath = defs.selectAll('#' + plotAreaClipId).data(dummyData);
clippingPath.enter().append('clipPath')
.attr('id', plotAreaClipId);
clippingPath.exit().remove();
// Clipping path rect
var clippingPathRect = clippingPath.selectAll('rect').data(dummyData);
clippingPathRect.enter().append('rect');
clippingPathRect
.attr('width', chartLayout.getPlotAreaWidth())
.attr('height', chartLayout.getPlotAreaHeight());
clippingPathRect.exit().remove();
// Create a background element
var plotAreaBackground = chart.selectAll('rect.background').data(dummyData);
plotAreaBackground.enter().append('rect')
.classed('background', true);
plotAreaBackground
.attr('width', chartLayout.getPlotAreaWidth())
.attr('height', chartLayout.getPlotAreaHeight());
plotAreaBackground.exit().remove();
// Create plot area, using the clipping path
// NOTE: We do not use a data-join to 'dummy data' here, because it is expected that the
// user (or chartBuilder) will want to data-join the plotArea with their own data in order
// that it is inherited by the series within the chart
var plotArea = chart.selectAll('g.plotArea');
if (plotArea.empty()) {
plotArea = chart.append('g')
.attr('clip-path', 'url(#' + plotAreaClipId + ')')
.attr('class', 'plotArea');
}
// Add selections to the chart elements object for the getters
chartElements = {
svg: svg,
chartArea: chart,
defs: defs,
plotAreaBackground: plotAreaBackground,
plotArea: plotArea
};
// Create containers for the axes
if (!chartElements.axisContainer) {
chartElements.axisContainer = {};
}
function createAxis(orientation, translation) {
var selection = chart.selectAll('g.axis.' + orientation).data(dummyData);
selection.enter().append('g')
.attr('class', 'axis ' + orientation);
selection.attr('transform', translation);
selection.exit().remove();
if (!chartElements.axisContainer[orientation]) {
chartElements.axisContainer[orientation] = {};
}
chartElements.axisContainer[orientation].selection = selection;
}
createAxis('bottom', 'translate(0, ' + chartLayout.getPlotAreaHeight() + ')');
createAxis('top', 'translate(0, 0)');
createAxis('left', 'translate(0, 0)');
createAxis('right', 'translate(' + chartLayout.getPlotAreaWidth() + ', 0)');
};
/**
* Get/set the size of the top margin between the chart area
* and the edge of its parent SVG.
*
* Increasing the size of a margin affords more space for an axis in the corresponding position.
*
* @memberof fc.utilities.chartLayout#
* @method marginTop
* @param {number} [value] The size of the top margin
* @returns {number|chartLayout} If value is specified, sets the top margin and returns the chartLayout;
* if value is not specified, returns the top margin.
*/
chartLayout.marginTop = function(value) {
if (!arguments.length) {
return margin.top;
}
margin.top = value;
return chartLayout;
};
/**
* Get/set the size of the right margin between the chart area
* and the edge of its parent SVG.
*
* Increasing the size of a margin affords more space for an axis in the corresponding position.
*
* @memberof fc.utilities.chartLayout#
* @method marginRight
* @param {number} [value] The size of the right margin
* @returns {number|chartLayout} If value is specified, sets the right margin and returns the chartLayout;
* if value is not specified, returns the right margin.
*/
chartLayout.marginRight = function(value) {
if (!arguments.length) {
return margin.right;
}
margin.right = value;
return chartLayout;
};
/**
* Get/set the size of the bottom margin between the chart area
* and the edge of its parent SVG.
*
* Increasing the size of a margin affords more space for an axis in the corresponding position.
*
* @memberof fc.utilities.chartLayout#
* @method marginBottom
* @param {number} [value] The size of the bottom margin
* @returns {number|chartLayout} If value is specified, sets the bottom margin and returns the chartLayout;
* if value is not specified, returns the bottom margin.
*/
chartLayout.marginBottom = function(value) {
if (!arguments.length) {
return margin.bottom;
}
margin.bottom = value;
return chartLayout;
};
/**
* Get/set the size of the left margin between the chart area
* and the edge of its parent SVG.
*
* Increasing the size of a margin affords more space for an axis in the corresponding position.
*
* @memberof fc.utilities.chartLayout#
* @method marginLeft
* @param {number} [value] The size of the left margin
* @returns {number|chartLayout} If value is specified, sets the left margin and returns the chartLayout;
* if value is not specified, returns the left margin.
*/
chartLayout.marginLeft = function(value) {
if (!arguments.length) {
return margin.left;
}
margin.left = value;
return chartLayout;
};
/**
* Get/set the width of the chart.
*
* If the width of the chart is not explicitly set before calling chartLayout on a selection,
* the component will attempt to size the chart to the dimensions of the selection's first element.
*
* @memberof fc.utilities.chartLayout#
* @method width
* @param {number} [value] The width of the chart
* @returns {number|chartLayout} If value is specified, sets the width and returns the chartLayout;
* if value is not specified, returns the width.
*/
chartLayout.width = function(value) {
if (!arguments.length) {
return width;
}
width = value;
defaultWidth = false;
return chartLayout;
};
/**
* Get/set the height of the chart.
*
* If the height of the chart is not explicitly set before calling chartLayout on a selection,
* the component will attempt to size the chart to the dimensions of the selection's first element.
*
* @memberof fc.utilities.chartLayout#
* @method height
* @param {number} [value] The height of the chart
* @returns {number|chartLayout} If value is specified, sets the height and returns the chartLayout;
* if value is not specified, returns the height.
*/
chartLayout.height = function(value) {
if (!arguments.length) {
return height;
}
height = value;
defaultHeight = false;
return chartLayout;
};
/**
* Get the width of the plot area. This is the total width of the chart minus the horizontal margins.
*
* @memberof fc.utilities.chartLayout#
* @method getPlotAreaWidth
* @returns {number} The width of the plot area.
*/
chartLayout.getPlotAreaWidth = function() {
return width - margin.left - margin.right;
};
/**
* Get the height of the plot area. This is the total height of the chart minus the vertical margins.
*
* @memberof fc.utilities.chartLayout#
* @method getPlotAreaHeight
* @returns {number} The height of the plot area.
*/
chartLayout.getPlotAreaHeight = function() {
return height - margin.top - margin.bottom;
};
/**
* Get the SVG for the chart.
*
* @memberof fc.utilities.chartLayout#
* @method getSVG
* @returns {selection} The SVG for the chart.
*/
chartLayout.getSVG = function() {
return chartElements.svg;
};
/**
* Get the defs element for the chart.
* The defs element can contain elements to be reused in the SVG, after they're defined;
* for example - a clipping path.
*
* @memberof fc.utilities.chartLayout#
* @method getDefs
* @returns {selection} The defs element for the chart.
*/
chartLayout.getDefs = function() {
return chartElements.defs;
};
/**
* Get the chart area group for the chart.
* Typically axes will be added to the chart area.
*
* @memberof fc.utilities.chartLayout#
* @method getChartArea
* @returns {selection} The chart's plot area.
*/
chartLayout.getChartArea = function() {
return chartElements.chartArea;
};
/**
* Get the plot area's background element.
*
* @memberof fc.utilities.chartLayout#
* @method getPlotAreaBackground
* @returns {selection} The background rect of the plot area.
*/
chartLayout.getPlotAreaBackground = function() {
return chartElements.plotAreaBackground;
};
/**
* Get the plot area group for the chart.
* The plot area has a clipping path, so this is typically where series and indicators will be added.
*
* @memberof fc.utilities.chartLayout#
* @method getPlotArea
* @returns {selection} The chart's plot area.
*/
chartLayout.getPlotArea = function() {
return chartElements.plotArea;
};
/**
* Get the group container for an axis.
*
* @memberof fc.utilities.chartLayout#
* @method getAxisContainer
* @param {string} orientation The orientation of the axis container;
* valid values are 'top', 'bottom', 'left' or 'right'
* @returns {selection} The group for the specified axis orientation.
*/
chartLayout.getAxisContainer = function(orientation) {
return chartElements.axisContainer[orientation].selection;
};
return chartLayout;
};
// Generates an integer ID
var nextId = (function() {
var id = 0;
return function() {
return ++id;
};
})();
}(d3, fc));
(function(d3, fc) {
'use strict';
/**
* The extent function enhances the functionality of the equivalent D3 extent function, allowing
* you to pass an array of fields which will be used to derive the extent of the supplied array. For
* example, if you have an array of items with properties of 'high' and 'low', you
* can use fc.utilities.extent(data, ['high', 'low'])
to compute the extent of your data.
*
* @memberof fc.utilities
* @param {array} data an array of data points, or an array of arrays of data points
* @param {array} fields the names of object properties that represent field values
*/
fc.utilities.extent = function(data, fields) {
if (fields === null) {
return d3.extent(data);
}
// the function only operates on arrays of arrays, but we can pass non-array types in
if (!Array.isArray(data)) {
data = [data];
}
// we need an array of arrays if we don't have one already
if (!Array.isArray(data[0])) {
data = [data];
}
// the fields parameter must be an array of field names, but we can pass non-array types in
if (!Array.isArray(fields)) {
fields = [fields];
}
// Return the smallest and largest
return [
d3.min(data, function(d0) {
return d3.min(d0, function(d1) {
return d3.min(fields.map(function(f) {
return d1[f];
}));
});
}),
d3.max(data, function(d0) {
return d3.max(d0, function(d1) {
return d3.max(fields.map(function(f) {
return d1[f];
}));
});
})
];
};
}(d3, fc));
(function(d3, fc) {
'use strict';
fc.utilities.fn = {
identity: function(d) { return d; },
noop: function(d) { }
};
}(d3, fc));
(function(d3, fc) {
'use strict';
// a property that follows the D3 component convention for accessors
// see: http://bost.ocks.org/mike/chart/
fc.utilities.property = function(initialValue) {
var accessor = function(newValue) {
if (!arguments.length) {
return accessor.value;
}
accessor.value = newValue;
return this;
};
accessor.value = initialValue;
return accessor;
};
// a property that follows the D3 component convention for accessors
// see: http://bost.ocks.org/mike/chart/
fc.utilities.functorProperty = function(initialValue) {
var accessor = function(newValue) {
if (!arguments.length) {
return accessor.value;
}
accessor.value = d3.functor(newValue);
return this;
};
accessor.value = d3.functor(initialValue);
return accessor;
};
}(d3, fc));