diff options
author | href <href@random.sh> | 2021-09-01 10:30:18 +0200 |
---|---|---|
committer | href <href@random.sh> | 2021-09-01 10:30:18 +0200 |
commit | 75687711f35355bc30e4829439384aab28fcac6d (patch) | |
tree | 8f3256f472893c39720a684d390e890a152f7303 /priv/static/js/metricsgraphics | |
parent | link: post_* callbacks; html & pdftitle. (diff) |
Commit all the changes that hasn't been committed + updates.
Diffstat (limited to 'priv/static/js/metricsgraphics')
30 files changed, 8299 insertions, 0 deletions
diff --git a/priv/static/js/metricsgraphics/MG.js b/priv/static/js/metricsgraphics/MG.js new file mode 100644 index 0000000..7f24a0f --- /dev/null +++ b/priv/static/js/metricsgraphics/MG.js @@ -0,0 +1 @@ +(typeof window === 'undefined' ? global : window).MG = {version: '2.11'}; diff --git a/priv/static/js/metricsgraphics/charts/bar.js b/priv/static/js/metricsgraphics/charts/bar.js new file mode 100644 index 0000000..b39d59f --- /dev/null +++ b/priv/static/js/metricsgraphics/charts/bar.js @@ -0,0 +1,792 @@ +{ + // TODO add styles to stylesheet instead + function scaffold({target, width, height, top, left, right, buffer}) { + const svg = mg_get_svg_child_of(target); + // main margins + svg.append('line') + .attr('x1', 0) + .attr('x2', width) + .attr('y1', top) + .attr('y2', top) + .attr('stroke', 'black'); + svg.append('line') + .attr('x1', 0) + .attr('x2', width) + .attr('y1', height - bottom) + .attr('y2', height - bottom) + .attr('stroke', 'black'); + + svg.append('line') + .attr('x1', left) + .attr('x2', left) + .attr('y1', 0) + .attr('y2', height) + .attr('stroke', 'black'); + + svg.append('line') + .attr('x1', width - right) + .attr('x2', width - right) + .attr('y1', 0) + .attr('y2', height) + .attr('stroke', 'black'); + + // plot area margins + svg.append('line') + .attr('x1', 0) + .attr('x2', width) + .attr('y1', height - bottom - buffer) + .attr('y2', height - bottom - buffer) + .attr('stroke', 'gray'); + + svg.append('line') + .attr('x1', 0) + .attr('x2', width) + .attr('y1', top + buffer) + .attr('y2', top + buffer) + .attr('stroke', 'gray'); + + svg.append('line') + .attr('x1', left + buffer) + .attr('x2', left + buffer) + .attr('y1', 0) + .attr('y2', args.height) + .attr('stroke', 'gray'); + svg.append('line') + .attr('x1', width - right - buffer) + .attr('x2', width - right - buffer) + .attr('y1', 0) + .attr('y2', height) + .attr('stroke', 'gray'); + } + + // barchart re-write. + function mg_targeted_legend({legend_target, orientation, scales}) { + let labels; + const plot = ''; + if (legend_target) { + + const div = d3.select(legend_target).append('div').classed('mg-bar-target-legend', true); + + if (orientation == 'horizontal') labels = scales.Y.domain(); + else labels = scales.X.domain(); + + labels.forEach(label => { + const outer_span = div.append('span').classed('mg-bar-target-element', true); + outer_span.append('span') + .classed('mg-bar-target-legend-shape', true) + .style('color', scales.COLOR(label)) + .text('\u25FC '); + outer_span.append('span') + .classed('mg-bar-target-legend-text', true) + .text(label); + }); + } + } + + function legend_on_graph(svg, args) { + // draw each element at the top right + // get labels + + let labels; + if (args.orientation=='horizontal') labels = args.scales.Y.domain(); + else labels = args.scales.X.domain(); + + let lineCount = 0; + const lineHeight = 1.1; + const g = svg.append('g').classed("mg-bar-legend", true); + const textContainer = g.append('text'); + + textContainer + .selectAll('*') + .remove(); + textContainer + .attr('width', args.right) + .attr('height', 100) + .attr('text-anchor', 'start'); + + labels.forEach(label => { + const sub_container = textContainer.append('tspan') + .attr('x', mg_get_plot_right(args)) + .attr('y', args.height / 2) + .attr('dy', `${lineCount * lineHeight}em`); + sub_container.append('tspan') + .text('\u25a0 ') + .attr('fill', args.scales.COLOR(label)) + .attr('font-size', 20); + sub_container.append('tspan') + .text(label) + .attr('font-weight', 300) + .attr('font-size', 10); + lineCount++; + }); + + // d.values.forEach(function (datum) { + // formatted_y = mg_format_y_rollover(args, num, datum); + + // if (args.y_rollover_format !== null) { + // formatted_y = number_rollover_format(args.y_rollover_format, datum, args.y_accessor); + // } else { + // formatted_y = args.yax_units + num(datum[args.y_accessor]); + // } + + // sub_container = textContainer.append('tspan').attr('x', 0).attr('y', (lineCount * lineHeight) + 'em'); + // formatted_y = mg_format_y_rollover(args, num, datum); + // mouseover_tspan(sub_container, '\u2014 ') + // .color(args, datum); + // mouseover_tspan(sub_container, formatted_x + ' ' + formatted_y); + + // lineCount++; + // }); + } + + function barChart(args) { + this.args = args; + + this.init = (args) => { + this.args = args; + args.x_axis_type = mg_infer_type(args, 'x'); + args.y_axis_type = mg_infer_type(args, 'y'); + + // this is specific to how rects work in svg, let's keep track of the bar orientation to + // plot appropriately. + if (args.x_axis_type == 'categorical') { + args.orientation = 'vertical'; + } else if (args.y_axis_type == 'categorical') { + args.orientation = 'horizontal'; + } else if (args.x_axis_type != 'categorical' && args.y_axis_type != 'categorical') { + // histogram. + args.orientation = 'vertical'; + } + + raw_data_transformation(args); + + process_point(args); + init(args); + + let xMaker; + let yMaker; + + if (args.x_axis_type === 'categorical') { + xMaker = MG.scale_factory(args) + .namespace('x') + .categoricalDomainFromData() + .categoricalRangeBands([0, args.xgroup_height], args.xgroup_accessor === null); + + if (args.xgroup_accessor) { + new MG.scale_factory(args) + .namespace('xgroup') + .categoricalDomainFromData() + .categoricalRangeBands('bottom'); + + } else { + args.scales.XGROUP = d => mg_get_plot_left(args); + args.scalefns.xgroupf = d => mg_get_plot_left(args); + } + + args.scalefns.xoutf = d => args.scalefns.xf(d) + args.scalefns.xgroupf(d); + } else { + xMaker = MG.scale_factory(args) + .namespace('x') + .inflateDomain(true) + .zeroBottom(args.y_axis_type === 'categorical') + .numericalDomainFromData((args.baselines || []).map(d => d[args.x_accessor])) + .numericalRange('bottom'); + + args.scalefns.xoutf = args.scalefns.xf; + } + + // y-scale generation. This needs to get simplified. + if (args.y_axis_type === 'categorical') { + yMaker = MG.scale_factory(args) + .namespace('y') + .zeroBottom(true) + .categoricalDomainFromData() + .categoricalRangeBands([0, args.ygroup_height], true); + + if (args.ygroup_accessor) { + + new MG.scale_factory(args) + .namespace('ygroup') + .categoricalDomainFromData() + .categoricalRangeBands('left'); + + } else { + args.scales.YGROUP = () => mg_get_plot_top(args); + args.scalefns.ygroupf = d => mg_get_plot_top(args); + + } + args.scalefns.youtf = d => args.scalefns.yf(d) + args.scalefns.ygroupf(d); + + } else { + const baselines = (args.baselines || []).map(d => d[args.y_accessor]); + + yMaker = MG.scale_factory(args) + .namespace('y') + .inflateDomain(true) + .zeroBottom(args.x_axis_type === 'categorical') + .numericalDomainFromData(baselines) + .numericalRange('left'); + + args.scalefns.youtf = d => args.scalefns.yf(d); + } + + if (args.ygroup_accessor !== null) { + args.ycolor_accessor = args.y_accessor; + MG.scale_factory(args) + .namespace('ycolor') + .scaleName('color') + .categoricalDomainFromData() + .categoricalColorRange(); + } + + if (args.xgroup_accessor !== null) { + args.xcolor_accessor = args.x_accessor; + MG.scale_factory(args) + .namespace('xcolor') + .scaleName('color') + .categoricalDomainFromData() + .categoricalColorRange(); + } + + // if (args.ygroup_accessor !== null) { + // MG.scale_factory(args) + // .namespace('ygroup') + // .categoricalDomainFromData() + // .categoricalColorRange(); + // } + + new MG.axis_factory(args) + .namespace('x') + .type(args.x_axis_type) + .zeroLine(args.y_axis_type === 'categorical') + .position(args.x_axis_position) + .draw(); + + new MG.axis_factory(args) + .namespace('y') + .type(args.y_axis_type) + .zeroLine(args.x_axis_type === 'categorical') + .position(args.y_axis_position) + .draw(); + + //mg_categorical_group_color_scale(args); + + this.mainPlot(); + this.markers(); + this.rollover(); + this.windowListeners(); + //scaffold(args) + + return this; + }; + + this.mainPlot = () => { + const svg = mg_get_svg_child_of(args.target); + const data = args.data[0]; + let barplot = svg.select('g.mg-barplot'); + const fresh_render = barplot.empty(); + + let bars, predictor_bars, pp, pp0, baseline_marks; + + const perform_load_animation = fresh_render && args.animate_on_load; + const should_transition = perform_load_animation || args.transition_on_update; + const transition_duration = args.transition_duration || 1000; + + // draw the plot on first render + if (fresh_render) { + barplot = svg.append('g') + .classed('mg-barplot', true); + } + + bars = barplot.selectAll('.mg-bar') + .data(data) + .enter() + .append('rect') + .classed('mg-bar', true) + .classed('default-bar', args.scales.hasOwnProperty('COLOR') ? false : true); + + // TODO - reimplement + + // reference_accessor {} + + // if (args.predictor_accessor) { + // predictor_bars = barplot.selectAll('.mg-bar-prediction') + // .data(data.filter(function(d) { + // return d.hasOwnProperty(args.predictor_accessor) })); + + // predictor_bars.exit().remove(); + + // predictor_bars.enter().append('rect') + // .classed('mg-bar-prediction', true); + // } + + // if (args.baseline_accessor) { + // baseline_marks = barplot.selectAll('.mg-bar-baseline') + // .data(data.filter(function(d) { + // return d.hasOwnProperty(args.baseline_accessor) })); + + // baseline_marks.exit().remove(); + + // baseline_marks.enter().append('line') + // .classed('mg-bar-baseline', true); + // } + + let appropriate_size; + + // setup transitions + // if (should_transition) { + // bars = bars.transition() + // .duration(transition_duration); + + // if (predictor_bars) { + // predictor_bars = predictor_bars.transition() + // .duration(transition_duration); + // } + + // if (baseline_marks) { + // baseline_marks = baseline_marks.transition() + // .duration(transition_duration); + // } + // } + + //appropriate_size = args.scales.Y_ingroup.rangeBand()/1.5; + let length, width, length_type, width_type, length_coord, width_coord, + length_scalefn, width_scalefn, length_scale, width_scale, + length_accessor, width_accessor, length_coord_map, width_coord_map, + length_map, width_map; + + let reference_length_map, reference_length_coord_fn; + + if (args.orientation == 'vertical') { + length = 'height'; + width = 'width'; + length_type = args.y_axis_type; + width_type = args.x_axis_type; + length_coord = 'y'; + width_coord = 'x'; + length_scalefn = length_type == 'categorical' ? args.scalefns.youtf : args.scalefns.yf; + width_scalefn = width_type == 'categorical' ? args.scalefns.xoutf : args.scalefns.xf; + length_scale = args.scales.Y; + width_scale = args.scales.X; + length_accessor = args.y_accessor; + width_accessor = args.x_accessor; + + length_coord_map = d => { + let l; + l = length_scalefn(d); + if (d[length_accessor] < 0) { + l = length_scale(0); + } + return l; + }; + + length_map = d => Math.abs(length_scalefn(d) - length_scale(0)); + + reference_length_map = d => Math.abs(length_scale(d[args.reference_accessor]) - length_scale(0)); + + reference_length_coord_fn = d => length_scale(d[args.reference_accessor]); + } + + if (args.orientation == 'horizontal') { + length = 'width'; + width = 'height'; + length_type = args.x_axis_type; + width_type = args.y_axis_type; + length_coord = 'x'; + width_coord = 'y'; + length_scalefn = length_type == 'categorical' ? args.scalefns.xoutf : args.scalefns.xf; + width_scalefn = width_type == 'categorical' ? args.scalefns.youtf : args.scalefns.yf; + length_scale = args.scales.X; + width_scale = args.scales.Y; + length_accessor = args.x_accessor; + width_accessor = args.y_accessor; + + length_coord_map = d => { + let l; + l = length_scale(0); + return l; + }; + + length_map = d => Math.abs(length_scalefn(d) - length_scale(0)); + + reference_length_map = d => Math.abs(length_scale(d[args.reference_accessor]) - length_scale(0)); + + reference_length_coord_fn = d => length_scale(0); + } + + // if (perform_load_animation) { + // bars.attr(length, 0); + + // if (predictor_bars) { + // predictor_bars.attr(length, 0); + // } + + // // if (baseline_marks) { + // // baseline_marks.attr({ + // // x1: args.scales.X(0), + // // x2: args.scales.X(0) + // // }); + // // } + // } + + bars.attr(length_coord, length_coord_map); + + // bars.attr(length_coord, 40) + //bars.attr(width_coord, 70) + + bars.attr(width_coord, d => { + let w; + if (width_type == 'categorical') { + w = width_scalefn(d); + } else { + w = width_scale(0); + if (d[width_accessor] < 0) { + w = width_scalefn(d); + } + } + w = w - args.bar_thickness/2; + return w; + }); + + if (args.scales.COLOR) { + bars.attr('fill', args.scalefns.colorf); + } + + bars + .attr(length, length_map) + .attr(width, d => args.bar_thickness); + + if (args.reference_accessor !== null) { + const reference_data = data.filter(d => d.hasOwnProperty(args.reference_accessor)); + const reference_bars = barplot.selectAll('.mg-categorical-reference') + .data(reference_data) + .enter() + .append('rect'); + + reference_bars + .attr(length_coord, reference_length_coord_fn) + .attr(width_coord, d => width_scalefn(d) - args.reference_thickness/2) + .attr(length, reference_length_map) + .attr(width, args.reference_thickness); + } + + if (args.comparison_accessor !== null) { + let comparison_thickness = null; + if (args.comparison_thickness === null) { + comparison_thickness = args.bar_thickness/2; + } else { + comparison_thickness = args.comparison_thickness; + } + + const comparison_data = data.filter(d => d.hasOwnProperty(args.comparison_accessor)); + const comparison_marks = barplot.selectAll('.mg-categorical-comparison') + .data(comparison_data) + .enter() + .append('line'); + + comparison_marks + .attr(`${length_coord}1`, d => length_scale(d[args.comparison_accessor])) + .attr(`${length_coord}2`, d => length_scale(d[args.comparison_accessor])) + .attr(`${width_coord}1`, d => width_scalefn(d) - comparison_thickness/2) + .attr(`${width_coord}2`, d => width_scalefn(d) + comparison_thickness/2) + .attr('stroke', 'black') + .attr('stroke-width', args.comparison_width); + } + + //bars.attr(width_coord, ); + // bars.attr('width', 50); + // bars.attr('height', 50); + // bars.attr('y', function(d){ + // var y = args.scales.Y(0); + // if (d[args.y_accessor] < 0) { + // y = args.scalefns.yf(d); + // } + // return y; + // }); + + // bars.attr('x', function(d){ + // return 40; + // }) + + // bars.attr('width', function(d){ + // return 100; + // }); + + // bars.attr('height', 100); + + // bars.attr('fill', 'black'); + // bars.attr('x', function(d) { + // var x = args.scales.X(0); + // if (d[args.x_accessor] < 0) { + // x = args.scalefns.xf(d); + // } + // return x; + // }) + // TODO - reimplement. + // if (args.predictor_accessor) { + // predictor_bars + // .attr('x', args.scales.X(0)) + // .attr('y', function(d) { + // return args.scalefns.ygroupf(d) + args.scalefns.yf(d) + args.scales.Y.rangeBand() * (7 / 16) // + pp0 * appropriate_size/(pp*2) + appropriate_size / 2; + // }) + // .attr('height', args.scales.Y.rangeBand() / 8) //appropriate_size / pp) + // .attr('width', function(d) { + // return args.scales.X(d[args.predictor_accessor]) - args.scales.X(0); + // }); + // } + + // TODO - reimplement. + // if (args.baseline_accessor) { + + // baseline_marks + // .attr('x1', function(d) { + // return args.scales.X(d[args.baseline_accessor]); }) + // .attr('x2', function(d) { + // return args.scales.X(d[args.baseline_accessor]); }) + // .attr('y1', function(d) { + // return args.scalefns.ygroupf(d) + args.scalefns.yf(d) + args.scales.Y.rangeBand() / 4 + // }) + // .attr('y2', function(d) { + // return args.scalefns.ygroupf(d) + args.scalefns.yf(d) + args.scales.Y.rangeBand() * 3 / 4 + // }); + // } + if (args.legend || (args.color_accessor !== null && args.ygroup_accessor !== args.color_accessor)) { + if (!args.legend_target) legend_on_graph(svg, args); + else mg_targeted_legend(args); + } + return this; + }; + + this.markers = () => { + markers(args); + return this; + }; + + this.rollover = () => { + const svg = mg_get_svg_child_of(args.target); + let g; + + if (svg.selectAll('.mg-active-datapoint-container').nodes().length === 0) { + mg_add_g(svg, 'mg-active-datapoint-container'); + } + + //remove the old rollovers if they already exist + svg.selectAll('.mg-rollover-rect').remove(); + svg.selectAll('.mg-active-datapoint').remove(); + + // get orientation + let length, width, length_type, width_type, length_coord, width_coord, + length_scalefn, width_scalefn, length_scale, width_scale, + length_accessor, width_accessor; + + let length_coord_map, width_coord_map, length_map, width_map; + + if (args.orientation == 'vertical') { + length = 'height'; + width = 'width'; + length_type = args.y_axis_type; + width_type = args.x_axis_type; + length_coord = 'y'; + width_coord = 'x'; + length_scalefn = length_type == 'categorical' ? args.scalefns.youtf : args.scalefns.yf; + width_scalefn = width_type == 'categorical' ? args.scalefns.xoutf : args.scalefns.xf; + length_scale = args.scales.Y; + width_scale = args.scales.X; + length_accessor = args.y_accessor; + width_accessor = args.x_accessor; + + length_coord_map = d => mg_get_plot_top(args); + + length_map = d => args.height -args.top-args.bottom-args.buffer*2; + } + + if (args.orientation == 'horizontal') { + length = 'width'; + width = 'height'; + length_type = args.x_axis_type; + width_type = args.y_axis_type; + length_coord = 'x'; + width_coord = 'y'; + length_scalefn = length_type == 'categorical' ? args.scalefns.xoutf : args.scalefns.xf; + width_scalefn = width_type == 'categorical' ? args.scalefns.youtf : args.scalefns.yf; + length_scale = args.scales.X; + width_scale = args.scales.Y; + length_accessor = args.x_accessor; + width_accessor = args.y_accessor; + + length_coord_map = d => { + let l; + l = length_scale(0); + return l; + }; + + length_map = d => args.width -args.left-args.right-args.buffer*2; + } + + //rollover text + let rollover_x, rollover_anchor; + if (args.rollover_align === 'right') { + rollover_x = args.width - args.right; + rollover_anchor = 'end'; + } else if (args.rollover_align === 'left') { + rollover_x = args.left; + rollover_anchor = 'start'; + } else { + rollover_x = (args.width - args.left - args.right) / 2 + args.left; + rollover_anchor = 'middle'; + } + + svg.append('text') + .attr('class', 'mg-active-datapoint') + .attr('xml:space', 'preserve') + .attr('x', rollover_x) + .attr('y', args.top * 0.75) + .attr('dy', '.35em') + .attr('text-anchor', rollover_anchor); + + g = svg.append('g') + .attr('class', 'mg-rollover-rect'); + + //draw rollover bars + const bars = g.selectAll(".mg-bar-rollover") + .data(args.data[0]).enter() + .append("rect") + .attr('class', 'mg-bar-rollover'); + + bars.attr('opacity', 0) + .attr(length_coord, length_coord_map) + .attr(width_coord, d => { + let w; + if (width_type == 'categorical') { + w = width_scalefn(d); + } else { + w = width_scale(0); + if (d[width_accessor] < 0) { + w = width_scalefn(d); + } + } + w = w - args.bar_thickness/2; + return w; + }); + + bars.attr(length, length_map); + bars.attr(width, d => args.bar_thickness); + + bars + .on('mouseover', this.rolloverOn(args)) + .on('mouseout', this.rolloverOff(args)) + .on('mousemove', this.rolloverMove(args)); + + return this; + }; + + this.rolloverOn = (args) => { + const svg = mg_get_svg_child_of(args.target); + const label_accessor = this.is_vertical ? args.x_accessor : args.y_accessor; + const data_accessor = this.is_vertical ? args.y_accessor : args.x_accessor; + const label_units = this.is_vertical ? args.yax_units : args.xax_units; + + return (d, i) => { + + const fmt = MG.time_format(args.utc_time, '%b %e, %Y'); + const num = format_rollover_number(args); + + //highlight active bar + const bar = svg.selectAll('g.mg-barplot .mg-bar') + .filter((d, j) => j === i).classed('active', true); + + if (args.scales.hasOwnProperty('COLOR')) { + bar.attr('fill', d3.rgb(args.scalefns.colorf(d)).darker()); + } else { + bar.classed('default-active', true); + } + + //update rollover text + if (args.show_rollover_text) { + const mouseover = mg_mouseover_text(args, { svg }); + let row = mouseover.mouseover_row(); + + if (args.ygroup_accessor) row.text(`${d[args.ygroup_accessor]} `).bold(); + + row.text(mg_format_x_mouseover(args, d)); + row.text(`${args.y_accessor}: ${d[args.y_accessor]}`); + if (args.predictor_accessor || args.baseline_accessor) { + row = mouseover.mouseover_row(); + + if (args.predictor_accessor) row.text(mg_format_data_for_mouseover(args, d, null, args.predictor_accessor, false)); + if (args.baseline_accessor) row.text(mg_format_data_for_mouseover(args, d, null, args.baseline_accessor, false)); + } + } + if (args.mouseover) { + args.mouseover(d, i); + } + }; + }; + + this.rolloverOff = (args) => { + const svg = mg_get_svg_child_of(args.target); + + return (d, i) => { + //reset active bar + const bar = svg.selectAll('g.mg-barplot .mg-bar.active').classed('active', false); + + if (args.scales.hasOwnProperty('COLOR')) { + bar.attr('fill', args.scalefns.colorf(d)); + } else { + bar.classed('default-active', false); + } + + //reset active data point text + svg.select('.mg-active-datapoint') + .text(''); + + mg_clear_mouseover_container(svg); + + if (args.mouseout) { + args.mouseout(d, i); + } + }; + }; + + this.rolloverMove = (args) => (d, i) => { + if (args.mousemove) { + args.mousemove(d, i); + } + }; + + this.windowListeners = () => { + mg_window_listeners(this.args); + return this; + }; + + this.init(args); + } + + const options = { + buffer: [16, 'number'], + y_accessor: ['factor', 'string'], + x_accessor: ['value', 'string'], + reference_accessor: [null, 'string'], + comparison_accessor: [null, 'string'], + secondary_label_accessor: [null, 'string'], + color_accessor: [null, 'string'], + color_type: ['category', ['number', 'category']], + color_domain: [null, 'number[]'], + reference_thickness: [1, 'number'], + comparison_width: [3, 'number'], + comparison_thickness: [null, 'number'], + legend: [false, 'boolean'], + legend_target: [null, 'string'], + mouseover_align: ['right', ['right', 'left']], + baseline_accessor: [null, 'string'], + predictor_accessor: [null, 'string'], + predictor_proportion: [5, 'number'], + show_bar_zero: [true, 'boolean'], + binned: [true, 'boolean'], + truncate_x_labels: [true, 'boolean'], + truncate_y_labels: [true, 'boolean'] + }; + + MG.register('bar', barChart, options); + +} diff --git a/priv/static/js/metricsgraphics/charts/histogram.js b/priv/static/js/metricsgraphics/charts/histogram.js new file mode 100644 index 0000000..3fca8e3 --- /dev/null +++ b/priv/static/js/metricsgraphics/charts/histogram.js @@ -0,0 +1,222 @@ +{ + function histogram(args) { + this.init = (args) => { + this.args = args; + + raw_data_transformation(args); + process_histogram(args); + init(args); + + new MG.scale_factory(args) + .namespace('x') + .numericalDomainFromData() + .numericalRange('bottom'); + + const baselines = (args.baselines || []).map(d => d[args.y_accessor]); + + new MG.scale_factory(args) + .namespace('y') + .zeroBottom(true) + .inflateDomain(true) + .numericalDomainFromData(baselines) + .numericalRange('left'); + + x_axis(args); + y_axis(args); + + this.mainPlot(); + this.markers(); + this.rollover(); + this.windowListeners(); + + return this; + }; + + this.mainPlot = () => { + const svg = mg_get_svg_child_of(args.target); + + //remove the old histogram, add new one + svg.selectAll('.mg-histogram').remove(); + + const g = svg.append('g') + .attr('class', 'mg-histogram'); + + const bar = g.selectAll('.mg-bar') + .data(args.data[0]) + .enter().append('g') + .attr('class', 'mg-bar') + .attr('transform', d => `translate(${args.scales.X(d[args.x_accessor]).toFixed(2)},${args.scales.Y(d[args.y_accessor]).toFixed(2)})`); + + //draw bars + bar.append('rect') + .attr('x', 1) + .attr('width', (d, i) => { + if (args.data[0].length === 1) { + return (args.scalefns.xf(args.data[0][0]) - args.bar_margin).toFixed(0); + } else if (i !== args.data[0].length - 1) { + return (args.scalefns.xf(args.data[0][i + 1]) - args.scalefns.xf(d)).toFixed(0); + } else { + return (args.scalefns.xf(args.data[0][1]) - args.scalefns.xf(args.data[0][0])).toFixed(0); + } + }) + .attr('height', d => { + if (d[args.y_accessor] === 0) { + return 0; + } + + return (args.height - args.bottom - args.buffer - args.scales.Y(d[args.y_accessor])).toFixed(2); + }); + + return this; + }; + + this.markers = () => { + markers(args); + return this; + }; + + this.rollover = () => { + const svg = mg_get_svg_child_of(args.target); + + if (svg.selectAll('.mg-active-datapoint-container').nodes().length === 0) { + mg_add_g(svg, 'mg-active-datapoint-container'); + } + + //remove the old rollovers if they already exist + svg.selectAll('.mg-rollover-rect').remove(); + svg.selectAll('.mg-active-datapoint').remove(); + + const g = svg.append('g') + .attr('class', 'mg-rollover-rect'); + + //draw rollover bars + const bar = g.selectAll('.mg-bar') + .data(args.data[0]) + .enter().append('g') + .attr('class', (d, i) => { + if (args.linked) { + return `mg-rollover-rects roll_${i}`; + } else { + return 'mg-rollover-rects'; + } + }) + .attr('transform', d => `translate(${args.scales.X(d[args.x_accessor])},${0})`); + + bar.append('rect') + .attr('x', 1) + .attr('y', args.buffer + (args.title ? args.title_y_position : 0)) + .attr('width', (d, i) => { + //if data set is of length 1 + if (args.data[0].length === 1) { + return (args.scalefns.xf(args.data[0][0]) - args.bar_margin).toFixed(0); + } else if (i !== args.data[0].length - 1) { + return (args.scalefns.xf(args.data[0][i + 1]) - args.scalefns.xf(d)).toFixed(0); + } else { + return (args.scalefns.xf(args.data[0][1]) - args.scalefns.xf(args.data[0][0])).toFixed(0); + } + }) + .attr('height', d => args.height) + .attr('opacity', 0) + .on('mouseover', this.rolloverOn(args)) + .on('mouseout', this.rolloverOff(args)) + .on('mousemove', this.rolloverMove(args)); + + return this; + }; + + this.rolloverOn = (args) => { + const svg = mg_get_svg_child_of(args.target); + + return (d, i) => { + svg.selectAll('text') + .filter((g, j) => d === g) + .attr('opacity', 0.3); + + const fmt = args.processed.xax_format || MG.time_format(args.utc_time, '%b %e, %Y'); + const num = format_rollover_number(args); + + svg.selectAll('.mg-bar rect') + .filter((d, j) => j === i) + .classed('active', true); + + //trigger mouseover on all matching bars + if (args.linked && !MG.globals.link) { + MG.globals.link = true; + + //trigger mouseover on matching bars in .linked charts + d3.selectAll(`.mg-rollover-rects.roll_${i} rect`) + .each(function(d) { //use existing i + d3.select(this).on('mouseover')(d, i); + }); + } + + //update rollover text + if (args.show_rollover_text) { + const mo = mg_mouseover_text(args, { svg }); + const row = mo.mouseover_row(); + row.text('\u259F ').elem + .classed('hist-symbol', true); + + row.text(mg_format_x_mouseover(args, d)); // x + row.text(mg_format_y_mouseover(args, d, args.time_series === false)); + } + + if (args.mouseover) { + mg_setup_mouseover_container(svg, args); + args.mouseover(d, i); + } + }; + }; + + this.rolloverOff = (args) => { + const svg = mg_get_svg_child_of(args.target); + + return (d, i) => { + if (args.linked && MG.globals.link) { + MG.globals.link = false; + + //trigger mouseout on matching bars in .linked charts + d3.selectAll(`.mg-rollover-rects.roll_${i} rect`) + .each(function(d) { //use existing i + d3.select(this).on('mouseout')(d, i); + }); + } + + //reset active bar + svg.selectAll('.mg-bar rect') + .classed('active', false); + + //reset active data point text + mg_clear_mouseover_container(svg); + + if (args.mouseout) { + args.mouseout(d, i); + } + }; + }; + + this.rolloverMove = (args) => (d, i) => { + if (args.mousemove) { + args.mousemove(d, i); + } + }; + + this.windowListeners = () => { + mg_window_listeners(this.args); + return this; + }; + + this.init(args); + } + + const options = { + bar_margin: [1, "number"], // the margin between bars + binned: [false, "boolean"], // determines whether the data is already binned + bins: [null, ['number', 'number[]', 'function']], // the number of bins to use. type: {null, number | thresholds | threshold_function} + processed_x_accessor: ['x', 'string'], + processed_y_accessor: ['y', 'string'], + processed_dx_accessor: ['dx', 'string'] + }; + + MG.register('histogram', histogram, options); +} diff --git a/priv/static/js/metricsgraphics/charts/line.js b/priv/static/js/metricsgraphics/charts/line.js new file mode 100644 index 0000000..f441af3 --- /dev/null +++ b/priv/static/js/metricsgraphics/charts/line.js @@ -0,0 +1,922 @@ +{ + function mg_line_color_text(elem, line_id, {color, colors}) { + elem.classed('mg-hover-line-color', color === null) + .classed(`mg-hover-line${line_id}-color`, colors === null) + .attr('fill', colors === null ? '' : colors[line_id - 1]); + } + + function mg_line_graph_generators(args, plot, svg) { + mg_add_line_generator(args, plot); + mg_add_area_generator(args, plot); + mg_add_flat_line_generator(args, plot); + mg_add_confidence_band_generator(args, plot, svg); + } + + function mg_add_confidence_band_generator(args, plot, svg) { + plot.existing_band = svg.selectAll('.mg-confidence-band').nodes(); + if (args.show_confidence_band) { + plot.confidence_area = d3.area() + .defined(plot.line.defined()) + .x(args.scalefns.xf) + .y0(d => { + const l = args.show_confidence_band[0]; + if (d[l] != undefined) { + return args.scales.Y(d[l]); + } else { + return args.scales.Y(d[args.y_accessor]); + } + }) + .y1(d => { + const u = args.show_confidence_band[1]; + if (d[u] != undefined) { + return args.scales.Y(d[u]); + } else { + return args.scales.Y(d[args.y_accessor]); + } + }) + .curve(args.interpolate); + } + } + + function mg_add_area_generator({scalefns, scales, interpolate, flip_area_under_y_value}, plot) { + + const areaBaselineValue = (Number.isFinite(flip_area_under_y_value)) ? scales.Y(flip_area_under_y_value) : scales.Y.range()[0]; + + plot.area = d3.area() + .defined(plot.line.defined()) + .x(scalefns.xf) + .y0(() => { + return areaBaselineValue; + }) + .y1(scalefns.yf) + .curve(interpolate); + } + + function mg_add_flat_line_generator({y_accessor, scalefns, scales, interpolate}, plot) { + plot.flat_line = d3.line() + .defined(d => (d['_missing'] === undefined || d['_missing'] !== true) && d[y_accessor] !== null) + .x(scalefns.xf) + .y(() => scales.Y(plot.data_median)) + .curve(interpolate); + } + + function mg_add_line_generator({scalefns, interpolate, missing_is_zero, y_accessor}, plot) { + plot.line = d3.line() + .x(scalefns.xf) + .y(scalefns.yf) + .curve(interpolate); + + // if missing_is_zero is not set, then hide data points that fall in missing + // data ranges or that have been explicitly identified as missing in the + // data source. + if (!missing_is_zero) { + // a line is defined if the _missing attrib is not set to true + // and the y-accessor is not null + plot.line = plot.line.defined(d => (d['_missing'] === undefined || d['_missing'] !== true) && d[y_accessor] !== null); + } + } + + function mg_add_confidence_band( + {show_confidence_band, transition_on_update, data, target}, + plot, + svg, + which_line + ) { + if (show_confidence_band) { + let confidenceBand; + if (svg.select(`.mg-confidence-band-${which_line}`).empty()) { + svg.append('path') + .attr('class', `mg-confidence-band mg-confidence-band-${which_line}`); + } + + // transition this line's confidence band + confidenceBand = svg.select(`.mg-confidence-band-${which_line}`); + + confidenceBand + .transition() + .duration(() => (transition_on_update) ? 1000 : 0) + .attr('d', plot.confidence_area(data[which_line - 1])) + .attr('clip-path', `url(#mg-plot-window-${mg_target_ref(target)})`); + } + } + + function mg_add_area({data, target, colors}, plot, svg, which_line, line_id) { + const areas = svg.selectAll(`.mg-main-area.mg-area${line_id}`); + if (plot.display_area) { + // if area already exists, transition it + if (!areas.empty()) { + svg.node().appendChild(areas.node()); + + areas.transition() + .duration(plot.update_transition_duration) + .attr('d', plot.area(data[which_line])) + .attr('clip-path', `url(#mg-plot-window-${mg_target_ref(target)})`); + } else { // otherwise, add the area + svg.append('path') + .classed('mg-main-area', true) + .classed(`mg-area${line_id}`, true) + .classed('mg-area-color', colors === null) + .classed(`mg-area${line_id}-color`, colors === null) + .attr('d', plot.area(data[which_line])) + .attr('fill', colors === null ? '' : colors[line_id - 1]) + .attr('clip-path', `url(#mg-plot-window-${mg_target_ref(target)})`); + } + } else if (!areas.empty()) { + areas.remove(); + } + } + + function mg_default_color_for_path(this_path, line_id) { + this_path.classed('mg-line-color', true) + .classed(`mg-line${line_id}-color`, true); + } + + function mg_color_line({colors}, this_path, which_line, line_id) { + if (colors) { + // for now, if args.colors is not an array, then keep moving as if nothing happened. + // if args.colors is not long enough, default to the usual line_id color. + if (colors.constructor === Array) { + this_path.attr('stroke', colors[which_line]); + if (colors.length < which_line + 1) { + // Go with default coloring. + // this_path.classed('mg-line' + (line_id) + '-color', true); + mg_default_color_for_path(this_path, line_id); + } + } else { + // this_path.classed('mg-line' + (line_id) + '-color', true); + mg_default_color_for_path(this_path, line_id); + } + } else { + // this is the typical workflow + // this_path.classed('mg-line' + (line_id) + '-color', true); + mg_default_color_for_path(this_path, line_id); + } + } + + function mg_add_line_element({animate_on_load, data, y_accessor, target}, plot, this_path, which_line) { + if (animate_on_load) { + plot.data_median = d3.median(data[which_line], d => d[y_accessor]); + this_path.attr('d', plot.flat_line(data[which_line])) + .transition() + .duration(1000) + .attr('d', plot.line(data[which_line])) + .attr('clip-path', `url(#mg-plot-window-${mg_target_ref(target)})`); + } else { // or just add the line + this_path.attr('d', plot.line(data[which_line])) + .attr('clip-path', `url(#mg-plot-window-${mg_target_ref(target)})`); + } + } + + function mg_add_line(args, plot, svg, existing_line, which_line, line_id) { + if (!existing_line.empty()) { + svg.node().appendChild(existing_line.node()); + + const lineTransition = existing_line.transition() + .duration(plot.update_transition_duration); + + if (!plot.display_area && args.transition_on_update && !args.missing_is_hidden) { + lineTransition.attrTween('d', path_tween(plot.line(args.data[which_line]), 4)); + } else { + lineTransition.attr('d', plot.line(args.data[which_line])); + } + } else { // otherwise... + // if we're animating on load, animate the line from its median value + const this_path = svg.append('path') + .attr('class', `mg-main-line mg-line${line_id}`); + + mg_color_line(args, this_path, which_line, line_id); + mg_add_line_element(args, plot, this_path, which_line); + } + } + + function mg_add_legend_element(args, plot, which_line, line_id) { + let this_legend; + if (args.legend) { + if (is_array(args.legend)) { + this_legend = args.legend[which_line]; + } else if (is_function(args.legend)) { + this_legend = args.legend(args.data[which_line]); + } + + if (args.legend_target) { + if (args.colors && args.colors.constructor === Array) { + plot.legend_text = `<span style='color:${args.colors[which_line]}'>— ${this_legend} </span>${plot.legend_text}`; + } else { + plot.legend_text = `<span class='mg-line${line_id}-legend-color'>— ${this_legend} </span>${plot.legend_text}`; + } + } else { + let anchor_point, anchor_orientation, dx; + + if (args.y_axis_position === 'left') { + anchor_point = args.data[which_line][args.data[which_line].length - 1]; + anchor_orientation = 'start'; + dx = args.buffer; + } else { + anchor_point = args.data[which_line][0]; + anchor_orientation = 'end'; + dx = -args.buffer; + } + const legend_text = plot.legend_group.append('svg:text') + .attr('x', args.scalefns.xf(anchor_point)) + .attr('dx', dx) + .attr('y', args.scalefns.yf(anchor_point)) + .attr('dy', '.35em') + .attr('font-size', 10) + .attr('text-anchor', anchor_orientation) + .attr('font-weight', '300') + .text(this_legend); + + if (args.colors && args.colors.constructor === Array) { + if (args.colors.length < which_line + 1) { + legend_text.classed(`mg-line${line_id}-legend-color`, true); + } else { + legend_text.attr('fill', args.colors[which_line]); + } + } else { + legend_text.classed('mg-line-legend-color', true) + .classed(`mg-line${line_id}-legend-color`, true); + } + + mg_prevent_vertical_overlap(plot.legend_group.selectAll('.mg-line-legend text').nodes(), args); + } + } + } + + function mg_plot_legend_if_legend_target(target, legend) { + if (target) d3.select(target).html(legend); + } + + function mg_add_legend_group({legend}, plot, svg) { + if (legend) plot.legend_group = mg_add_g(svg, 'mg-line-legend'); + } + + function mg_remove_existing_line_rollover_elements(svg) { + // remove the old rollovers if they already exist + mg_selectAll_and_remove(svg, '.mg-rollover-rect'); + mg_selectAll_and_remove(svg, '.mg-voronoi'); + + // remove the old rollover text and circle if they already exist + mg_selectAll_and_remove(svg, '.mg-active-datapoint'); + mg_selectAll_and_remove(svg, '.mg-line-rollover-circle'); + //mg_selectAll_and_remove(svg, '.mg-active-datapoint-container'); + } + + function mg_add_rollover_circle({data, colors}, svg) { + // append circle + const circle = svg.selectAll('.mg-line-rollover-circle') + .data(data) + .enter().append('circle') + .attr('cx', 0) + .attr('cy', 0) + .attr('r', 0); + + if (colors && colors.constructor === Array) { + circle + .attr('class', ({__line_id__}) => `mg-line${__line_id__}`) + .attr('fill', (d, i) => colors[i]) + .attr('stroke', (d, i) => colors[i]); + } else { + circle.attr('class', ({__line_id__}, i) => [ + `mg-line${__line_id__}`, + `mg-line${__line_id__}-color`, + `mg-area${__line_id__}-color` + ].join(' ')); + } + circle.classed('mg-line-rollover-circle', true); + } + + function mg_set_unique_line_id_for_each_series({data, custom_line_color_map}) { + // update our data by setting a unique line id for each series + // increment from 1... unless we have a custom increment series + + for (let i = 0; i < data.length; i++) { + data[i].forEach(datum => { + datum.__index__ = i + 1; + datum.__line_id__ = (custom_line_color_map.length > 0) ? custom_line_color_map[i] : i + 1; + }); + } + } + + function mg_nest_data_for_voronoi({data}) { + return d3.merge(data); + } + + function mg_line_class_string(args) { + return d => { + let class_string; + + if (args.linked) { + const v = d[args.x_accessor]; + const formatter = MG.time_format(args.utc_time, args.linked_format); + + // only format when x-axis is date + const id = (typeof v === 'number') ? (d.__line_id__ - 1) : formatter(v); + class_string = `roll_${id} mg-line${d.__line_id__}`; + + if (args.color === null) { + class_string += ` mg-line${d.__line_id__}-color`; + } + return class_string; + + } else { + class_string = `mg-line${d.__line_id__}`; + if (args.color === null) class_string += ` mg-line${d.__line_id__}-color`; + return class_string; + } + }; + } + + function mg_add_voronoi_rollover(args, svg, rollover_on, rollover_off, rollover_move, rollover_click) { + const voronoi = d3.voronoi() + .x(d => args.scales.X(d[args.x_accessor]).toFixed(2)) + .y(d => args.scales.Y(d[args.y_accessor]).toFixed(2)) + .extent([ + [args.buffer, args.buffer + (args.title ? args.title_y_position : 0)], + [args.width - args.buffer, args.height - args.buffer] + ]); + + const g = mg_add_g(svg, 'mg-voronoi'); + g.selectAll('path') + .data(voronoi.polygons(mg_nest_data_for_voronoi(args))) + .enter() + .append('path') + .filter(d => d !== undefined && d.length > 0) + .attr('d', d => d == null ? null : `M${d.join('L')}Z`) + .datum(d => d == null ? null : d.data) // because of d3.voronoi, reassign d + .attr('class', mg_line_class_string(args)) + .on('click', rollover_click) + .on('mouseover', rollover_on) + .on('mouseout', rollover_off) + .on('mousemove', rollover_move); + + mg_configure_voronoi_rollover(args, svg); + } + + function nest_data_for_aggregate_rollover({x_accessor, data, x_sort}) { + const data_nested = d3.nest() + .key(d => d[x_accessor]) + .entries(d3.merge(data)); + data_nested.forEach(entry => { + const datum = entry.values[0]; + entry.key = datum[x_accessor]; + }); + + if (x_sort) { + return data_nested.sort((a, b) => new Date(a.key) - new Date(b.key)); + } else { + return data_nested; + } + } + + function mg_add_aggregate_rollover(args, svg, rollover_on, rollover_off, rollover_move, rollover_click) { + // Undo the keys getting coerced to strings, by setting the keys from the values + // This is necessary for when we have X axis keys that are things like + const data_nested = nest_data_for_aggregate_rollover(args); + + const xf = data_nested.map(({key}) => args.scales.X(key)); + + const g = svg.append('g') + .attr('class', 'mg-rollover-rect'); + + g.selectAll('.mg-rollover-rects') + .data(data_nested).enter() + .append('rect') + .attr('x', (d, i) => { + if (xf.length === 1) return mg_get_plot_left(args); + else if (i === 0) return xf[i].toFixed(2); + else return ((xf[i - 1] + xf[i]) / 2).toFixed(2); + }) + .attr('y', args.top) + .attr('width', (d, i) => { + if (xf.length === 1) return mg_get_plot_right(args); + else if (i === 0) return ((xf[i + 1] - xf[i]) / 2).toFixed(2); + else if (i === xf.length - 1) return ((xf[i] - xf[i - 1]) / 2).toFixed(2); + else return ((xf[i + 1] - xf[i - 1]) / 2).toFixed(2); + }) + .attr('class', ({values}) => { + let line_classes = values.map(({__line_id__}) => { + let lc = mg_line_class(__line_id__); + if (args.colors === null) lc += ` ${mg_line_color_class(__line_id__)}`; + return lc; + }).join(' '); + if (args.linked && values.length > 0) { + line_classes += ` ${mg_rollover_id_class(mg_rollover_format_id(values[0], args))}`; + } + + return line_classes; + }) + .attr('height', args.height - args.bottom - args.top - args.buffer) + .attr('opacity', 0) + .on('click', rollover_click) + .on('mouseover', rollover_on) + .on('mouseout', rollover_off) + .on('mousemove', rollover_move); + + mg_configure_aggregate_rollover(args, svg); + } + + function mg_configure_singleton_rollover({data}, svg) { + svg.select('.mg-rollover-rect rect') + .on('mouseover')(data[0][0], 0); + } + + function mg_configure_voronoi_rollover({data, custom_line_color_map}, svg) { + for (let i = 0; i < data.length; i++) { + let j = i + 1; + + if (custom_line_color_map.length > 0 && + custom_line_color_map[i] !== undefined) { + j = custom_line_color_map[i]; + } + + if (data[i].length === 1 && !svg.selectAll(`.mg-voronoi .mg-line${j}`).empty()) { + svg.selectAll(`.mg-voronoi .mg-line${j}`) + .on('mouseover')(data[i][0], 0); + + svg.selectAll(`.mg-voronoi .mg-line${j}`) + .on('mouseout')(data[i][0], 0); + } + } + } + + function mg_line_class(line_id) { + return `mg-line${line_id}`; + } + + function mg_line_color_class(line_id) { + return `mg-line${line_id}-color`; + } + + function mg_rollover_id_class(id) { + return `roll_${id}`; + } + + function mg_rollover_format_id(d, {x_accessor, utc_time, linked_format}) { + const v = d[x_accessor]; + const formatter = MG.time_format(utc_time, linked_format); + // only format when x-axis is date + return (typeof v === 'number') ? v.toString().replace('.', '_') : formatter(v); + } + + function mg_add_single_line_rollover(args, svg, rollover_on, rollover_off, rollover_move, rollover_click) { + // set to 1 unless we have a custom increment series + let line_id = 1; + if (args.custom_line_color_map.length > 0) { + line_id = args.custom_line_color_map[0]; + } + + const g = svg.append('g') + .attr('class', 'mg-rollover-rect'); + + const xf = args.data[0].map(args.scalefns.xf); + + g.selectAll('.mg-rollover-rects') + .data(args.data[0]).enter() + .append('rect') + .attr('class', (d, i) => { + let cl = `${mg_line_color_class(line_id)} ${mg_line_class(d.__line_id__)}`; + if (args.linked) cl += `${cl} ${mg_rollover_id_class(mg_rollover_format_id(d, args))}`; + return cl; + }) + .attr('x', (d, i) => { + // if data set is of length 1 + if (xf.length === 1) return mg_get_plot_left(args); + else if (i === 0) return xf[i].toFixed(2); + else return ((xf[i - 1] + xf[i]) / 2).toFixed(2); + }) + .attr('y', (d, i) => (args.data.length > 1) ? args.scalefns.yf(d) - 6 // multi-line chart sensitivity + : args.top) + .attr('width', (d, i) => { + // if data set is of length 1 + if (xf.length === 1) return mg_get_plot_right(args); + else if (i === 0) return ((xf[i + 1] - xf[i]) / 2).toFixed(2); + else if (i === xf.length - 1) return ((xf[i] - xf[i - 1]) / 2).toFixed(2); + else return ((xf[i + 1] - xf[i - 1]) / 2).toFixed(2); + }) + .attr('height', (d, i) => (args.data.length > 1) ? 12 // multi-line chart sensitivity + : args.height - args.bottom - args.top - args.buffer) + .attr('opacity', 0) + .on('click', rollover_click) + .on('mouseover', rollover_on) + .on('mouseout', rollover_off) + .on('mousemove', rollover_move); + + if (mg_is_singleton(args)) { + mg_configure_singleton_rollover(args, svg); + } + } + + function mg_configure_aggregate_rollover({data}, svg) { + const rect = svg.selectAll('.mg-rollover-rect rect'); + const rect_first = rect.nodes()[0][0] || rect.nodes()[0]; + if (data.filter(({length}) => length === 1).length > 0) { + rect.on('mouseover')(rect_first.__data__, 0); + } + } + + function mg_is_standard_multiline({data, aggregate_rollover}) { + return data.length > 1 && !aggregate_rollover; + } + + function mg_is_aggregated_rollover({data, aggregate_rollover}) { + return data.length > 1 && aggregate_rollover; + } + + function mg_is_singleton({data}) { + return data.length === 1 && data[0].length === 1; + } + + function mg_draw_all_line_elements(args, plot, svg) { + mg_remove_dangling_bands(plot, svg); + + // If option activated, remove existing active points if exists + if (args.active_point_on_lines) { + svg.selectAll('circle.mg-shown-active-point').remove(); + } + + for (let i = args.data.length - 1; i >= 0; i--) { + const this_data = args.data[i]; + + // passing the data for the current line + MG.call_hook('line.before_each_series', [this_data, args]); + + // override increment if we have a custom increment series + let line_id = i + 1; + if (args.custom_line_color_map.length > 0) { + line_id = args.custom_line_color_map[i]; + } + + args.data[i].__line_id__ = line_id; + + // If option activated, add active points for each lines + if (args.active_point_on_lines) { + svg.selectAll('circle-' + line_id) + .data(args.data[i]) + .enter() + .filter((d) => { + return d[args.active_point_accessor]; + }) + .append('circle') + .attr('class', 'mg-area' + (line_id) + '-color mg-shown-active-point') + .attr('cx', args.scalefns.xf) + .attr('cy', args.scalefns.yf) + .attr('r', () => { + return args.active_point_size; + }); + } + + const existing_line = svg.select(`path.mg-main-line.mg-line${line_id}`); + if (this_data.length === 0) { + existing_line.remove(); + continue; + } + + mg_add_confidence_band(args, plot, svg, line_id); + + if (Array.isArray(args.area)) { + if (args.area[line_id - 1]) { + mg_add_area(args, plot, svg, i, line_id); + } + } else { + mg_add_area(args, plot, svg, i, line_id); + } + + mg_add_line(args, plot, svg, existing_line, i, line_id); + mg_add_legend_element(args, plot, i, line_id); + + // passing the data for the current line + MG.call_hook('line.after_each_series', [this_data, existing_line, args]); + } + } + + function mg_remove_dangling_bands({existing_band}, svg) { + if (existing_band[0] && existing_band[0].length > svg.selectAll('.mg-main-line').node().length) { + svg.selectAll('.mg-confidence-band').remove(); + } + } + + function mg_line_main_plot(args) { + const plot = {}; + const svg = mg_get_svg_child_of(args.target); + + // remove any old legends if they exist + mg_selectAll_and_remove(svg, '.mg-line-legend'); + mg_add_legend_group(args, plot, svg); + + plot.data_median = 0; + plot.update_transition_duration = (args.transition_on_update) ? 1000 : 0; + plot.display_area = (args.area && !args.use_data_y_min && args.data.length <= 1 && args.aggregate_rollover === false) || (Array.isArray(args.area) && args.area.length > 0); + plot.legend_text = ''; + mg_line_graph_generators(args, plot, svg); + plot.existing_band = svg.selectAll('.mg-confidence-band').nodes(); + + // should we continue with the default line render? A `line.all_series` hook should return false to prevent the default. + const continueWithDefault = MG.call_hook('line.before_all_series', [args]); + if (continueWithDefault !== false) { + mg_draw_all_line_elements(args, plot, svg); + } + + mg_plot_legend_if_legend_target(args.legend_target, plot.legend_text); + } + + function mg_line_rollover_setup(args, graph) { + const svg = mg_get_svg_child_of(args.target); + + if (args.showActivePoint && svg.selectAll('.mg-active-datapoint-container').nodes().length === 0) { + mg_add_g(svg, 'mg-active-datapoint-container'); + } + + mg_remove_existing_line_rollover_elements(svg); + mg_add_rollover_circle(args, svg); + mg_set_unique_line_id_for_each_series(args); + + if (mg_is_standard_multiline(args)) { + mg_add_voronoi_rollover(args, svg, graph.rolloverOn(args), graph.rolloverOff(args), graph.rolloverMove(args), graph.rolloverClick(args)); + } else if (mg_is_aggregated_rollover(args)) { + mg_add_aggregate_rollover(args, svg, graph.rolloverOn(args), graph.rolloverOff(args), graph.rolloverMove(args), graph.rolloverClick(args)); + } else { + mg_add_single_line_rollover(args, svg, graph.rolloverOn(args), graph.rolloverOff(args), graph.rolloverMove(args), graph.rolloverClick(args)); + } + } + + function mg_update_rollover_circle(args, svg, d) { + if (args.aggregate_rollover && args.data.length > 1) { + // hide the circles in case a non-contiguous series is present + svg.selectAll('circle.mg-line-rollover-circle') + .style('opacity', 0); + + d.values.forEach((datum, index, list) => { + if (args.missing_is_hidden && list[index]['_missing']) { + return; + } + + if (mg_data_in_plot_bounds(datum, args)) mg_update_aggregate_rollover_circle(args, svg, datum); + }); + } else if ((args.missing_is_hidden && d['_missing']) || d[args.y_accessor] === null) { + // disable rollovers for hidden parts of the line + // recall that hidden parts are missing data ranges and possibly also + // data points that have been explicitly identified as missing + return; + } else { + // show circle on mouse-overed rect + if (mg_data_in_plot_bounds(d, args)) { + mg_update_generic_rollover_circle(args, svg, d); + } + } + } + + function mg_update_aggregate_rollover_circle({scales, x_accessor, y_accessor, point_size}, svg, datum) { + svg.select(`circle.mg-line-rollover-circle.mg-line${datum.__line_id__}`) + .attr('cx', scales.X(datum[x_accessor]).toFixed(2)) + .attr('cy', scales.Y(datum[y_accessor]).toFixed(2)) + .attr('r', point_size) + .style('opacity', 1); + } + + function mg_update_generic_rollover_circle({scales, x_accessor, y_accessor, point_size}, svg, d) { + svg.selectAll(`circle.mg-line-rollover-circle.mg-line${d.__line_id__}`) + .classed('mg-line-rollover-circle', true) + .attr('cx', () => scales.X(d[x_accessor]).toFixed(2)) + .attr('cy', () => scales.Y(d[y_accessor]).toFixed(2)) + .attr('r', point_size) + .style('opacity', 1); + } + + function mg_trigger_linked_mouseovers(args, d, i) { + if (args.linked && !MG.globals.link) { + MG.globals.link = true; + if (!args.aggregate_rollover || d[args.y_accessor] !== undefined || (d.values && d.values.length > 0)) { + const datum = d.values ? d.values[0] : d; + const id = mg_rollover_format_id(datum, args); + // trigger mouseover on matching line in .linked charts + d3.selectAll(`.${mg_line_class(datum.__line_id__)}.${mg_rollover_id_class(id)}`) + .each(function(d) { + d3.select(this) + .on('mouseover')(d, i); + }); + } + } + } + + function mg_trigger_linked_mouseouts({linked, utc_time, linked_format, x_accessor}, d, i) { + if (linked && MG.globals.link) { + MG.globals.link = false; + + const formatter = MG.time_format(utc_time, linked_format); + const datums = d.values ? d.values : [d]; + datums.forEach(datum => { + const v = datum[x_accessor]; + const id = (typeof v === 'number') ? i : formatter(v); + + // trigger mouseout on matching line in .linked charts + d3.selectAll(`.roll_${id}`) + .each(function(d) { + d3.select(this) + .on('mouseout')(d); + }); + }); + } + } + + function mg_remove_active_data_points_for_aggregate_rollover(args, svg) { + svg.selectAll('circle.mg-line-rollover-circle').filter(({length}) => length > 1) + .style('opacity', 0); + } + + function mg_remove_active_data_points_for_generic_rollover({custom_line_color_map, data}, svg, line_id) { + svg.selectAll(`circle.mg-line-rollover-circle.mg-line${line_id}`) + .style('opacity', () => { + let id = line_id - 1; + if (custom_line_color_map.length > 0 && + custom_line_color_map.indexOf(line_id) !== undefined + ) { + id = custom_line_color_map.indexOf(line_id); + } + + if (data[id].length === 1) { + return 1; + } else { + return 0; + } + }); + } + + function mg_remove_active_text(svg) { + svg.select('.mg-active-datapoint').text(''); + } + + function lineChart(args) { + this.init = function(args) { + this.args = args; + + if (!args.data || args.data.length === 0) { + args.internal_error = 'No data was supplied'; + internal_error(args); + return this; + } else { + args.internal_error = undefined; + } + + raw_data_transformation(args); + process_line(args); + + MG.call_hook('line.before_destroy', this); + + init(args); + + // TODO incorporate markers into calculation of x scales + new MG.scale_factory(args) + .namespace('x') + .numericalDomainFromData() + .numericalRange('bottom'); + + const baselines = (args.baselines || []).map(d => d[args.y_accessor]); + + new MG.scale_factory(args) + .namespace('y') + .zeroBottom(true) + .inflateDomain(true) + .numericalDomainFromData(baselines) + .numericalRange('left'); + + if (args.x_axis) { + new MG.axis_factory(args) + .namespace('x') + .type('numerical') + .position(args.x_axis_position) + .rug(x_rug(args)) + .label(mg_add_x_label) + .draw(); + } + + if (args.y_axis) { + new MG.axis_factory(args) + .namespace('y') + .type('numerical') + .position(args.y_axis_position) + .rug(y_rug(args)) + .label(mg_add_y_label) + .draw(); + } + + this.markers(); + this.mainPlot(); + this.rollover(); + this.windowListeners(); + if (args.brush) MG.add_brush_function(args); + MG.call_hook('line.after_init', this); + + return this; + }; + + this.mainPlot = function() { + mg_line_main_plot(args); + return this; + }; + + this.markers = function() { + markers(args); + return this; + }; + + this.rollover = function() { + mg_line_rollover_setup(args, this); + MG.call_hook('line.after_rollover', args); + + return this; + }; + + this.rolloverClick = args => (d, i) => { + if (args.click) { + args.click(d, i); + } + }; + + this.rolloverOn = args => { + const svg = mg_get_svg_child_of(args.target); + + return (d, i) => { + mg_update_rollover_circle(args, svg, d); + mg_trigger_linked_mouseovers(args, d, i); + + svg.selectAll('text') + .filter((g, j) => d === g) + .attr('opacity', 0.3); + + // update rollover text except for missing data points + if (args.show_rollover_text && + !((args.missing_is_hidden && d['_missing']) || d[args.y_accessor] === null) + ) { + const mouseover = mg_mouseover_text(args, { svg }); + let row = mouseover.mouseover_row(); + if (args.aggregate_rollover) { + row.text((args.aggregate_rollover && args.data.length > 1 + ? mg_format_x_aggregate_mouseover + : mg_format_x_mouseover)(args, d)); + } + + const pts = args.aggregate_rollover && args.data.length > 1 + ? d.values + : [d]; + + pts.forEach(di => { + if (args.aggregate_rollover) { + row = mouseover.mouseover_row(); + } + + if (args.legend) { + mg_line_color_text(row.text(`${args.legend[di.__index__ - 1]} `).bold(), di.__line_id__, args); + } + + mg_line_color_text(row.text('\u2014 ').elem, di.__line_id__, args); + if (!args.aggregate_rollover) { + row.text(mg_format_x_mouseover(args, di)); + } + + row.text(mg_format_y_mouseover(args, di, args.time_series === false)); + }); + } + + if (args.mouseover) { + args.mouseover(d, i); + } + }; + }; + + this.rolloverOff = args => { + const svg = mg_get_svg_child_of(args.target); + + return (d, i) => { + mg_trigger_linked_mouseouts(args, d, i); + if (args.aggregate_rollover) { + mg_remove_active_data_points_for_aggregate_rollover(args, svg); + } else { + mg_remove_active_data_points_for_generic_rollover(args, svg, d.__line_id__); + } + + if (args.data[0].length > 1) { + mg_clear_mouseover_container(svg); + } + + if (args.mouseout) { + args.mouseout(d, i); + } + }; + }; + + this.rolloverMove = args => (d, i) => { + if (args.mousemove) { + args.mousemove(d, i); + } + }; + + this.windowListeners = function() { + mg_window_listeners(this.args); + return this; + }; + + this.init(args); + } + + MG.register('line', lineChart); +} diff --git a/priv/static/js/metricsgraphics/charts/missing.js b/priv/static/js/metricsgraphics/charts/missing.js new file mode 100644 index 0000000..330d5c0 --- /dev/null +++ b/priv/static/js/metricsgraphics/charts/missing.js @@ -0,0 +1,144 @@ +{ + function mg_missing_add_text(svg, {missing_text, width, height}) { + svg.selectAll('.mg-missing-text').data([missing_text]) + .enter().append('text') + .attr('class', 'mg-missing-text') + .attr('x', width / 2) + .attr('y', height / 2) + .attr('dy', '.50em') + .attr('text-anchor', 'middle') + .text(missing_text); + } + + function mg_missing_x_scale(args) { + args.scales.X = d3.scaleLinear() + .domain([0, args.data.length]) + .range([mg_get_plot_left(args), mg_get_plot_right(args)]); + args.scalefns.yf = ({y}) => args.scales.Y(y); + } + + function mg_missing_y_scale(args) { + args.scales.Y = d3.scaleLinear() + .domain([-2, 2]) + .range([args.height - args.bottom - args.buffer * 2, args.top]); + args.scalefns.xf = ({x}) => args.scales.X(x); + } + + function mg_make_fake_data(args) { + const data = []; + for (let x = 1; x <= 50; x++) { + data.push({ x, y: Math.random() - (x * 0.03) }); + } + args.data = data; + } + + function mg_add_missing_background_rect(g, {title, buffer, title_y_position, width, height}) { + g.append('svg:rect') + .classed('mg-missing-background', true) + .attr('x', buffer) + .attr('y', buffer + (title ? title_y_position : 0) * 2) + .attr('width', width - buffer * 2) + .attr('height', height - buffer * 2 - (title ? title_y_position : 0) * 2) + .attr('rx', 15) + .attr('ry', 15); + } + + function mg_missing_add_line(g, {scalefns, interpolate, data}) { + const line = d3.line() + .x(scalefns.xf) + .y(scalefns.yf) + .curve(interpolate); + + g.append('path') + .attr('class', 'mg-main-line mg-line1-color') + .attr('d', line(data)); + } + + function mg_missing_add_area(g, {scalefns, scales, interpolate, data}) { + const area = d3.area() + .x(scalefns.xf) + .y0(scales.Y.range()[0]) + .y1(scalefns.yf) + .curve(interpolate); + + g.append('path') + .attr('class', 'mg-main-area mg-area1-color') + .attr('d', area(data)); + } + + function mg_remove_all_children({target}) { + d3.select(target).selectAll('svg *').remove(); + } + + function mg_missing_remove_legend({legend_target}) { + if (legend_target) { + d3.select(legend_target).html(''); + } + } + + function missingData(args) { + this.init = (args) => { + this.args = args; + + mg_init_compute_width(args); + mg_init_compute_height(args); + + // create svg if one doesn't exist + + const container = d3.select(args.target); + mg_raise_container_error(container, args); + let svg = container.selectAll('svg'); + mg_remove_svg_if_chart_type_has_changed(svg, args); + svg = mg_add_svg_if_it_doesnt_exist(svg, args); + mg_adjust_width_and_height_if_changed(svg, args); + mg_set_viewbox_for_scaling(svg, args); + mg_remove_all_children(args); + + svg.classed('mg-missing', true); + mg_missing_remove_legend(args); + + chart_title(args); + + // are we adding a background placeholder + if (args.show_missing_background) { + mg_make_fake_data(args); + mg_missing_x_scale(args); + mg_missing_y_scale(args); + const g = mg_add_g(svg, 'mg-missing-pane'); + + mg_add_missing_background_rect(g, args); + mg_missing_add_line(g, args); + mg_missing_add_area(g, args); + } + + mg_missing_add_text(svg, args); + + this.windowListeners(); + + return this; + }; + + this.windowListeners = () => { + mg_window_listeners(this.args); + return this; + }; + + this.init(args); + } + + const defaults = { + top: [40, 'number'], // the size of the top margin + bottom: [30, 'number'], // the size of the bottom margin + right: [10, 'number'], // size of the right margin + left: [0, 'number'], // size of the left margin + buffer: [8, 'number'], // the buffer between the actual chart area and the margins + legend_target: ['', 'string'], + width: [350, 'number'], + height: [220, 'number'], + missing_text: ['Data currently missing or unavailable', 'string'], + show_tooltips: [true, 'boolean'], + show_missing_background: [true, 'boolean'] + }; + + MG.register('missing-data', missingData, defaults); +} diff --git a/priv/static/js/metricsgraphics/charts/point.js b/priv/static/js/metricsgraphics/charts/point.js new file mode 100644 index 0000000..b511f19 --- /dev/null +++ b/priv/static/js/metricsgraphics/charts/point.js @@ -0,0 +1,383 @@ +function point_mouseover(args, svg, d) { + const mouseover = mg_mouseover_text(args, { svg }); + const row = mouseover.mouseover_row(); + + if (args.color_accessor !== null && args.color_type === 'category') { + const label = d[args.color_accessor]; + row.text(`${label} `).bold().attr('fill', args.scalefns.colorf(d)); + } + + mg_color_point_mouseover(args, row.text('\u25CF ').elem, d); // point shape + + row.text(mg_format_x_mouseover(args, d)); // x + row.text(mg_format_y_mouseover(args, d, args.time_series === false)); +} + +function mg_color_point_mouseover({color_accessor, scalefns}, elem, d) { + if (color_accessor !== null) { + elem.attr('fill', scalefns.colorf(d)); + elem.attr('stroke', scalefns.colorf(d)); + } else { + elem.classed('mg-points-mono', true); + } +} + + +{ + function mg_filter_out_plot_bounds(data, args) { + // max_x, min_x, max_y, min_y; + const x = args.x_accessor; + const y = args.y_accessor; + const new_data = data.filter(d => (args.min_x === null || d[x] >= args.min_x) && + (args.max_x === null || d[x] <= args.max_x) && + (args.min_y === null || d[y] >= args.min_y) && + (args.max_y === null || d[y] <= args.max_y)); + return new_data; + } + + function pointChart(args) { + this.init = function(args) { + this.args = args; + + // infer y_axis and x_axis type; + args.x_axis_type = mg_infer_type(args, 'x'); + args.y_axis_type = mg_infer_type(args, 'y'); + + raw_data_transformation(args); + + process_point(args); + init(args); + + let xMaker, yMaker; + + if (args.x_axis_type === 'categorical') { + xMaker = MG.scale_factory(args) + .namespace('x') + .categoricalDomainFromData() + .categoricalRangeBands([0, args.xgroup_height], args.xgroup_accessor === null); + + if (args.xgroup_accessor) { + new MG.scale_factory(args) + .namespace('xgroup') + .categoricalDomainFromData() + .categoricalRangeBands('bottom'); + + } else { + args.scales.XGROUP = () => mg_get_plot_left(args); + args.scalefns.xgroupf = () => mg_get_plot_left(args); + } + + args.scalefns.xoutf = d => args.scalefns.xf(d) + args.scalefns.xgroupf(d); + } else { + xMaker = MG.scale_factory(args) + .namespace('x') + .inflateDomain(true) + .zeroBottom(args.y_axis_type === 'categorical') + .numericalDomainFromData((args.baselines || []).map(d => d[args.x_accessor])) + .numericalRange('bottom'); + + args.scalefns.xoutf = args.scalefns.xf; + } + + // y-scale generation. This needs to get simplified. + if (args.y_axis_type === 'categorical') { + yMaker = MG.scale_factory(args) + .namespace('y') + .zeroBottom(true) + .categoricalDomainFromData() + .categoricalRangeBands([0, args.ygroup_height], true); + + if (args.ygroup_accessor) { + + new MG.scale_factory(args) + .namespace('ygroup') + .categoricalDomainFromData() + .categoricalRangeBands('left'); + + } else { + args.scales.YGROUP = () => mg_get_plot_top(args); + args.scalefns.ygroupf = () => mg_get_plot_top(args); + + } + args.scalefns.youtf = d => args.scalefns.yf(d) + args.scalefns.ygroupf(d); + + } else { + const baselines = (args.baselines || []).map(d => d[args.y_accessor]); + yMaker = MG.scale_factory(args) + .namespace('y') + .inflateDomain(true) + .zeroBottom(args.x_axis_type === 'categorical') + .numericalDomainFromData(baselines) + .numericalRange('left'); + + args.scalefns.youtf = d => args.scalefns.yf(d); + } + + /////// COLOR accessor + if (args.color_accessor !== null) { + const colorScale = MG.scale_factory(args).namespace('color'); + if (args.color_type === 'number') { + // do the color scale. + // etiher get color range, or what. + colorScale + .numericalDomainFromData(mg_get_color_domain(args)) + .numericalRange(mg_get_color_range(args)) + .clamp(true); + } else { + if (args.color_domain) { + colorScale + .categoricalDomain(args.color_domain) + .categoricalRange(args.color_range); + } else { + colorScale + .categoricalDomainFromData() + .categoricalColorRange(); + } + } + } + + if (args.size_accessor) { + new MG.scale_factory(args).namespace('size') + .numericalDomainFromData() + .numericalRange(mg_get_size_range(args)) + .clamp(true); + } + + new MG.axis_factory(args) + .namespace('x') + .type(args.x_axis_type) + .zeroLine(args.y_axis_type === 'categorical') + .position(args.x_axis_position) + .rug(x_rug(args)) + .label(mg_add_x_label) + .draw(); + + new MG.axis_factory(args) + .namespace('y') + .type(args.y_axis_type) + .zeroLine(args.x_axis_type === 'categorical') + .position(args.y_axis_position) + .rug(y_rug(args)) + .label(mg_add_y_label) + .draw(); + + this.mainPlot(); + this.markers(); + this.rollover(); + this.windowListeners(); + if (args.brush) MG.add_brush_function(args); + return this; + }; + + this.markers = function() { + markers(args); + if (args.least_squares) { + add_ls(args); + } + + return this; + }; + + this.mainPlot = function() { + const svg = mg_get_svg_child_of(args.target); + + const data = mg_filter_out_plot_bounds(args.data[0], args); + //remove the old points, add new one + svg.selectAll('.mg-points').remove(); + + const g = svg.append('g') + .classed('mg-points', true); + + const pts = g.selectAll('circle') + .data(data) + .enter().append('circle') + .attr('class', (d, i) => `path-${i}`) + .attr('cx', args.scalefns.xoutf) + .attr('cy', d => args.scalefns.youtf(d)); + + let highlights; + svg.selectAll('.mg-highlight').remove(); + if (args.highlight && mg_is_function(args.highlight)) { + highlights = svg.append('g') + .classed('mg-highlight', true) + .selectAll('circle') + .data(data.filter(args.highlight)) + .enter().append('circle') + .attr('cx', args.scalefns.xoutf) + .attr('cy', d => args.scalefns.youtf(d)); + } + + const elements = [pts].concat(highlights ? [highlights] : []); + //are we coloring our points, or just using the default color? + if (args.color_accessor !== null) { + elements.forEach(e => e.attr('fill', args.scalefns.colorf).attr('stroke', args.scalefns.colorf)); + } else { + elements.forEach(e => e.classed('mg-points-mono', true)); + } + + pts.attr('r', (args.size_accessor !== null) ? args.scalefns.sizef : args.point_size); + if (highlights) { + highlights.attr('r', (args.size_accessor !== null) ? (d, i) => args.scalefns.sizef(d, i) + 2 : args.point_size + 2); + } + + return this; + }; + + this.rollover = function() { + const svg = mg_get_svg_child_of(args.target); + + if (svg.selectAll('.mg-active-datapoint-container').nodes().length === 0) { + mg_add_g(svg, 'mg-active-datapoint-container'); + } + + //remove the old rollovers if they already exist + svg.selectAll('.mg-voronoi').remove(); + + //add rollover paths + const voronoi = d3.voronoi() + .x(args.scalefns.xoutf) + .y(args.scalefns.youtf) + .extent([ + [args.buffer, args.buffer + (args.title ? args.title_y_position : 0)], + [args.width - args.buffer, args.height - args.buffer] + ]); + + const paths = svg.append('g') + .attr('class', 'mg-voronoi'); + + paths.selectAll('path') + .data(voronoi.polygons(mg_filter_out_plot_bounds(args.data[0], args))) + .enter().append('path') + .attr('d', d => d == null ? null : `M${d.join(',')}Z`) + .attr('class', (d, i) => `path-${i}`) + .style('fill-opacity', 0) + .on('click', this.rolloverClick(args)) + .on('mouseover', this.rolloverOn(args)) + .on('mouseout', this.rolloverOff(args)) + .on('mousemove', this.rolloverMove(args)); + + if (args.data[0].length === 1) { + point_mouseover(args, svg, args.data[0][0]); + } + + return this; + }; + + this.rolloverClick = args => { + return (d, i) => { + if (args.click) { + args.click(d, i); + } + }; + }; + + this.rolloverOn = args => { + const svg = mg_get_svg_child_of(args.target); + + return (d, i) => { + svg.selectAll('.mg-points circle') + .classed('selected', false); + + //highlight active point + const pts = svg.selectAll(`.mg-points circle.path-${i}`) + .classed('selected', true); + + if (args.size_accessor) { + pts.attr('r', di => args.scalefns.sizef(di) + args.active_point_size_increase); + } else { + pts.attr('r', args.point_size + args.active_point_size_increase); + } + + //trigger mouseover on all points for this class name in .linked charts + if (args.linked && !MG.globals.link) { + MG.globals.link = true; + + //trigger mouseover on matching point in .linked charts + d3.selectAll(`.mg-voronoi .path-${i}`) + .each(() => { + d3.select(this).on('mouseover')(d, i); + }); + } + + if (args.show_rollover_text) { + point_mouseover(args, svg, d.data); + } + + if (args.mouseover) { + args.mouseover(d, i); + } + }; + }; + + this.rolloverOff = args => { + const svg = mg_get_svg_child_of(args.target); + + return (d, i) => { + if (args.linked && MG.globals.link) { + MG.globals.link = false; + + d3.selectAll(`.mg-voronoi .path-${i}`) + .each(() => { + d3.select(this).on('mouseout')(d, i); + }); + } + + //reset active point + const pts = svg.selectAll('.mg-points circle') + .classed('unselected', false) + .classed('selected', false); + + if (args.size_accessor) { + pts.attr('r', args.scalefns.sizef); + } else { + pts.attr('r', args.point_size); + } + + //reset active data point text + if (args.data[0].length > 1) mg_clear_mouseover_container(svg); + + if (args.mouseout) { + args.mouseout(d, i); + } + }; + }; + + this.rolloverMove = args => (d, i) => { + if (args.mousemove) { + args.mousemove(d, i); + } + }; + + this.update = function(args) { + return this; + }; + + this.windowListeners = function() { + mg_window_listeners(this.args); + return this; + }; + + this.init(args); + } + + const options = { + color_accessor: [null, 'string'], // the data element to use to map points to colors + color_range: [null, 'array'], // the range used to color different groups of points + color_type: ['number', ['number', 'category']], // specifies whether the color scale is quantitative or qualitative + point_size: [2.5, 'number'], // the radius of the dots in the scatterplot + size_accessor: [null, 'string'], // should point sizes be mapped to data + size_range: [null, 'array'], // the range of point sizes + lowess: [false, 'boolean'], // specifies whether to show a lowess line of best-fit + least_squares: [false, 'boolean'], // specifies whether to show a least-squares line of best-fit + y_categorical_show_guides: [true, 'boolean'], + x_categorical_show_guides: [true, 'boolean'], + buffer: [16, 'string'], + label_accessor: [null, 'boolean'], + size_domain: [null, 'array'], + color_domain: [null, 'array'], + active_point_size_increase: [1, 'number'], + highlight: [null, 'function'] // if this callback function returns true, the selected point will be highlighted + }; + + MG.register('point', pointChart, options); +} diff --git a/priv/static/js/metricsgraphics/charts/table.js b/priv/static/js/metricsgraphics/charts/table.js new file mode 100644 index 0000000..3081d8c --- /dev/null +++ b/priv/static/js/metricsgraphics/charts/table.js @@ -0,0 +1,220 @@ +/* +Data Tables + +Along with histograms, bars, lines, and scatters, a simple data table can take you far. +We often just want to look at numbers, organized as a table, where columns are variables, +and rows are data points. Sometimes we want a cell to have a small graphic as the main +column element, in which case we want small multiples. sometimes we want to + +var table = New data_table(data) + .target('div#data-table') + .title({accessor: 'point_name', align: 'left'}) + .description({accessor: 'description'}) + .number({accessor: ''}) + +*/ + +MG.data_table = function(args) { + 'use strict'; + this.args = args; + this.args.standard_col = { width: 150, font_size: 12, font_weight: 'normal' }; + this.args.columns = []; + this.formatting_options = [ + ['color', 'color'], + ['font-weight', 'font_weight'], + ['font-style', 'font_style'], + ['font-size', 'font_size'] + ]; + + this._strip_punctuation = function(s) { + var punctuationless = s.replace(/[^a-zA-Z0-9 _]+/g, ''); + var finalString = punctuationless.replace(/ +?/g, ''); + return finalString; + }; + + this._format_element = function(element, value, args) { + this.formatting_options.forEach(function(fo) { + var attr = fo[0]; + var key = fo[1]; + if (args[key]) element.style(attr, + typeof args[key] === 'string' || + typeof args[key] === 'number' ? + args[key] : args[key](value)); + }); + }; + + this._add_column = function(_args, arg_type) { + var standard_column = this.args.standard_col; + var args = merge_with_defaults(MG.clone(_args), MG.clone(standard_column)); + args.type = arg_type; + this.args.columns.push(args); + }; + + this.target = function() { + var target = arguments[0]; + this.args.target = target; + return this; + }; + + this.title = function() { + this._add_column(arguments[0], 'title'); + return this; + }; + + this.text = function() { + this._add_column(arguments[0], 'text'); + return this; + }; + + this.bullet = function() { + /* + text label + main value + comparative measure + any number of ranges + + additional args: + no title + xmin, xmax + format: percentage + xax_formatter + */ + return this; + }; + + this.sparkline = function() { + return this; + }; + + this.number = function() { + this._add_column(arguments[0], 'number'); + return this; + }; + + this.display = function() { + var args = this.args; + + chart_title(args); + + var target = args.target; + var table = d3.select(target).append('table').classed('mg-data-table', true); + var colgroup = table.append('colgroup'); + var thead = table.append('thead'); + var tbody = table.append('tbody'); + var this_column; + var this_title; + + var tr, th, td_accessor, td_type, td_value, th_text, td_text, td; + var col; + var h; + + tr = thead.append('tr'); + + for (h = 0; h < args.columns.length; h++) { + var this_col = args.columns[h]; + td_type = this_col.type; + th_text = this_col.label; + th_text = th_text === undefined ? '' : th_text; + th = tr.append('th') + .style('width', this_col.width) + .style('text-align', td_type === 'title' ? 'left' : 'right') + .text(th_text); + + if (args.show_tooltips && this_col.description && mg_jquery_exists()) { + th.append('i') + .classed('fa', true) + .classed('fa-question-circle', true) + .classed('fa-inverse', true); + + $(th.node()).popover({ + html: true, + animation: false, + content: this_col.description, + trigger: 'hover', + placement: 'top', + container: $(th.node()) + }); + } + } + + for (h = 0; h < args.columns.length; h++) { + col = colgroup.append('col'); + if (args.columns[h].type === 'number') { + col.attr('align', 'char').attr('char', '.'); + } + } + + for (var i = 0; i < args.data.length; i++) { + tr = tbody.append('tr'); + for (var j = 0; j < args.columns.length; j++) { + this_column = args.columns[j]; + td_accessor = this_column.accessor; + td_value = td_text = args.data[i][td_accessor]; + td_type = this_column.type; + + if (td_type === 'number') { + //td_text may need to be rounded + if (this_column.hasOwnProperty('round') && !this_column.hasOwnProperty('format')) { + // round according to the number value in this_column.round + td_text = d3.format('0,.' + this_column.round + 'f')(td_text); + } + + if (this_column.hasOwnProperty('value_formatter')) { + // provide a function that formats the text according to the function this_column.format. + td_text = this_column.value_formatter(td_text); + } + + if (this_column.hasOwnProperty('format')) { + // this is a shorthand for percentage formatting, and others if need be. + // supported: 'percentage', 'count', 'temperature' + + if (this_column.round) { + td_text = Math.round(td_text, this_column.round); + } + + var this_format = this_column.format; + var formatter; + + if (this_format === 'percentage') formatter = d3.format('.0%'); + if (this_format === 'count') formatter = d3.format(',.0f'); + if (this_format === 'temperature') formatter = function(t) { + return t + '°'; }; + + td_text = formatter(td_text); + } + + if (this_column.hasOwnProperty('currency')) { + // this is another shorthand for formatting according to a currency amount, which gets appended to front of number + td_text = this_column.currency + td_text; + } + } + + td = tr.append('td') + .classed('table-' + td_type, true) + .classed('table-' + td_type + '-' + this._strip_punctuation(td_accessor), true) + .attr('data-value', td_value) + .style('width', this_column.width) + .style('text-align', td_type === 'title' || td_type === 'text' ? 'left' : 'right'); + + this._format_element(td, td_value, this_column); + + if (td_type === 'title') { + this_title = td.append('div').text(td_text); + this._format_element(this_title, td_text, this_column); + + if (args.columns[j].hasOwnProperty('secondary_accessor')) { + td.append('div') + .text(args.data[i][args.columns[j].secondary_accessor]) + .classed("secondary-title", true); + } + } else { + td.text(td_text); + } + } + } + + return this; + }; + + return this; +}; diff --git a/priv/static/js/metricsgraphics/common/bootstrap_tooltip_popover.js b/priv/static/js/metricsgraphics/common/bootstrap_tooltip_popover.js new file mode 100755 index 0000000..3d1d5b7 --- /dev/null +++ b/priv/static/js/metricsgraphics/common/bootstrap_tooltip_popover.js @@ -0,0 +1,626 @@ +if (mg_jquery_exists()) { + /* ======================================================================== + * Bootstrap: tooltip.js v3.3.5 + * http://getbootstrap.com/javascript/#tooltip + * Inspired by the original jQuery.tipsy by Jason Frame + * ======================================================================== + * Copyright 2011-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * ======================================================================== */ + + + +function ($) { + 'use strict'; + + // TOOLTIP PUBLIC CLASS DEFINITION + // =============================== + + var Tooltip = function (element, options) { + this.type = null; + this.options = null; + this.enabled = null; + this.timeout = null; + this.hoverState = null; + this.$element = null; + this.inState = null; + + this.init('tooltip', element, options); + }; + + Tooltip.VERSION = '3.3.5'; + + Tooltip.TRANSITION_DURATION = 150; + + Tooltip.DEFAULTS = { + animation: true, + placement: 'top', + selector: false, + template: '<div class="tooltip" role="tooltip"><div class="tooltip-arrow"></div><div class="tooltip-inner"></div></div>', + trigger: 'hover focus', + title: '', + delay: 0, + html: false, + container: false, + viewport: { + selector: 'body', + padding: 0 + } + }; + + Tooltip.prototype.init = function (type, element, options) { + this.enabled = true; + this.type = type; + this.$element = $(element); + this.options = this.getOptions(options); + this.$viewport = this.options.viewport && $($.isFunction(this.options.viewport) ? this.options.viewport.call(this, this.$element) : (this.options.viewport.selector || this.options.viewport)); + this.inState = { click: false, hover: false, focus: false }; + + if (this.$element[0] instanceof document.constructor && !this.options.selector) { + throw new Error('`selector` option must be specified when initializing ' + this.type + ' on the window.document object!'); + } + + var triggers = this.options.trigger.split(' '); + + for (var i = triggers.length; i--;) { + var trigger = triggers[i]; + + if (trigger == 'click') { + this.$element.on('click.' + this.type, this.options.selector, $.proxy(this.toggle, this)); + } else if (trigger != 'manual') { + var eventIn = trigger == 'hover' ? 'mouseenter' : 'focusin'; + var eventOut = trigger == 'hover' ? 'mouseleave' : 'focusout'; + + this.$element.on(eventIn + '.' + this.type, this.options.selector, $.proxy(this.enter, this)); + this.$element.on(eventOut + '.' + this.type, this.options.selector, $.proxy(this.leave, this)); + } + } + + this.options.selector ? + (this._options = $.extend({}, this.options, { trigger: 'manual', selector: '' })) : + this.fixTitle(); + }; + + Tooltip.prototype.getDefaults = function () { + return Tooltip.DEFAULTS; + }; + + Tooltip.prototype.getOptions = function (options) { + options = $.extend({}, this.getDefaults(), this.$element.data(), options); + + if (options.delay && typeof options.delay == 'number') { + options.delay = { + show: options.delay, + hide: options.delay + }; + } + + return options; + }; + + Tooltip.prototype.getDelegateOptions = function () { + var options = {}; + var defaults = this.getDefaults(); + + this._options && $.each(this._options, function (key, value) { + if (defaults[key] != value) options[key] = value; + }); + + return options; + }; + + Tooltip.prototype.enter = function (obj) { + var self = obj instanceof this.constructor ? + obj : $(obj.currentTarget).data('bs.' + this.type); + + if (!self) { + self = new this.constructor(obj.currentTarget, this.getDelegateOptions()); + $(obj.currentTarget).data('bs.' + this.type, self); + } + + if (obj instanceof $.Event) { + self.inState[obj.type == 'focusin' ? 'focus' : 'hover'] = true; + } + + if (self.tip().hasClass('in') || self.hoverState == 'in') { + self.hoverState = 'in'; + return; + } + + clearTimeout(self.timeout); + + self.hoverState = 'in'; + + if (!self.options.delay || !self.options.delay.show) return self.show(); + + self.timeout = setTimeout(function () { + if (self.hoverState == 'in') self.show(); + }, self.options.delay.show); + }; + + Tooltip.prototype.isInStateTrue = function () { + for (var key in this.inState) { + if (this.inState[key]) return true; + } + + return false; + }; + + Tooltip.prototype.leave = function (obj) { + var self = obj instanceof this.constructor ? + obj : $(obj.currentTarget).data('bs.' + this.type); + + if (!self) { + self = new this.constructor(obj.currentTarget, this.getDelegateOptions()); + $(obj.currentTarget).data('bs.' + this.type, self); + } + + if (obj instanceof $.Event) { + self.inState[obj.type == 'focusout' ? 'focus' : 'hover'] = false; + } + + if (self.isInStateTrue()) return; + + clearTimeout(self.timeout); + + self.hoverState = 'out'; + + if (!self.options.delay || !self.options.delay.hide) return self.hide(); + + self.timeout = setTimeout(function () { + if (self.hoverState == 'out') self.hide(); + }, self.options.delay.hide); + }; + + Tooltip.prototype.show = function () { + var e = $.Event('show.bs.' + this.type); + + if (this.hasContent() && this.enabled) { + this.$element.trigger(e); + + var inDom = $.contains(this.$element[0].ownerDocument.documentElement, this.$element[0]); + if (e.isDefaultPrevented() || !inDom) return; + var that = this; + + var $tip = this.tip(); + + var tipId = this.getUID(this.type); + + this.setContent(); + $tip.attr('id', tipId); + this.$element.attr('aria-describedby', tipId); + + if (this.options.animation) $tip.addClass('fade'); + + var placement = typeof this.options.placement == 'function' ? + this.options.placement.call(this, $tip[0], this.$element[0]) : + this.options.placement; + + var autoToken = /\s?auto?\s?/i; + var autoPlace = autoToken.test(placement); + if (autoPlace) placement = placement.replace(autoToken, '') || 'top'; + + $tip + .detach() + .css({ top: 0, left: 0, display: 'block' }) + .addClass(placement) + .data('bs.' + this.type, this); + + this.options.container ? $tip.appendTo(this.options.container) : $tip.insertAfter(this.$element); + this.$element.trigger('inserted.bs.' + this.type); + + var pos = this.getPosition(); + var actualWidth = $tip[0].offsetWidth; + var actualHeight = $tip[0].offsetHeight; + + if (autoPlace) { + var orgPlacement = placement; + var viewportDim = this.getPosition(this.$viewport); + + placement = placement == 'bottom' && pos.bottom + actualHeight > viewportDim.bottom ? 'top' : + placement == 'top' && pos.top - actualHeight < viewportDim.top ? 'bottom' : + placement == 'right' && pos.right + actualWidth > viewportDim.width ? 'left' : + placement == 'left' && pos.left - actualWidth < viewportDim.left ? 'right' : + placement; + + $tip + .removeClass(orgPlacement) + .addClass(placement); + } + + var calculatedOffset = this.getCalculatedOffset(placement, pos, actualWidth, actualHeight); + + this.applyPlacement(calculatedOffset, placement); + + var complete = function () { + var prevHoverState = that.hoverState; + that.$element.trigger('shown.bs.' + that.type); + that.hoverState = null; + + if (prevHoverState == 'out') that.leave(that); + }; + + $.support.transition && this.$tip.hasClass('fade') ? + $tip + .one('bsTransitionEnd', complete) + .emulateTransitionEnd(Tooltip.TRANSITION_DURATION) : + complete(); + } + }; + + Tooltip.prototype.applyPlacement = function (offset, placement) { + var $tip = this.tip(); + var width = $tip[0].offsetWidth; + var height = $tip[0].offsetHeight; + + // manually read margins because getBoundingClientRect includes difference + var marginTop = parseInt($tip.css('margin-top'), 10); + var marginLeft = parseInt($tip.css('margin-left'), 10); + + // we must check for NaN for ie 8/9 + if (isNaN(marginTop)) marginTop = 0; + if (isNaN(marginLeft)) marginLeft = 0; + + offset.top += marginTop; + offset.left += marginLeft; + + // $.fn.offset doesn't round pixel values + // so we use setOffset directly with our own function B-0 + $.offset.setOffset($tip[0], $.extend({ + using: function (props) { + $tip.css({ + top: Math.round(props.top), + left: Math.round(props.left) + }); + } + }, offset), 0); + + $tip.addClass('in'); + + // check to see if placing tip in new offset caused the tip to resize itself + var actualWidth = $tip[0].offsetWidth; + var actualHeight = $tip[0].offsetHeight; + + if (placement == 'top' && actualHeight != height) { + offset.top = offset.top + height - actualHeight; + } + + var delta = this.getViewportAdjustedDelta(placement, offset, actualWidth, actualHeight); + + if (delta.left) offset.left += delta.left; + else offset.top += delta.top; + + var isVertical = /top|bottom/.test(placement); + var arrowDelta = isVertical ? delta.left * 2 - width + actualWidth : delta.top * 2 - height + actualHeight; + var arrowOffsetPosition = isVertical ? 'offsetWidth' : 'offsetHeight'; + + $tip.offset(offset); + this.replaceArrow(arrowDelta, $tip[0][arrowOffsetPosition], isVertical); + }; + + Tooltip.prototype.replaceArrow = function (delta, dimension, isVertical) { + this.arrow() + .css(isVertical ? 'left' : 'top', 50 * (1 - delta / dimension) + '%') + .css(isVertical ? 'top' : 'left', ''); + }; + + Tooltip.prototype.setContent = function () { + var $tip = this.tip(); + var title = this.getTitle(); + + $tip.find('.tooltip-inner')[this.options.html ? 'html' : 'text'](title); + $tip.removeClass('fade in top bottom left right'); + }; + + Tooltip.prototype.hide = function (callback) { + var that = this; + var $tip = $(this.$tip); + var e = $.Event('hide.bs.' + this.type); + + function complete() { + if (that.hoverState != 'in') $tip.detach(); + that.$element + .removeAttr('aria-describedby') + .trigger('hidden.bs.' + that.type); + callback && callback(); + } + + this.$element.trigger(e); + + if (e.isDefaultPrevented()) return; + + $tip.removeClass('in'); + + $.support.transition && $tip.hasClass('fade') ? + $tip + .one('bsTransitionEnd', complete) + .emulateTransitionEnd(Tooltip.TRANSITION_DURATION) : + complete(); + + this.hoverState = null; + + return this; + }; + + Tooltip.prototype.fixTitle = function () { + var $e = this.$element; + if ($e.attr('title') || typeof $e.attr('data-original-title') != 'string') { + $e.attr('data-original-title', $e.attr('title') || '').attr('title', ''); + } + }; + + Tooltip.prototype.hasContent = function () { + return this.getTitle(); + }; + + Tooltip.prototype.getPosition = function ($element) { + $element = $element || this.$element; + + var el = $element[0]; + var isBody = el.tagName == 'BODY'; + + var elRect = el.getBoundingClientRect(); + if (elRect.width == null) { + // width and height are missing in IE8, so compute them manually; see https://github.com/twbs/bootstrap/issues/14093 + elRect = $.extend({}, elRect, { width: elRect.right - elRect.left, height: elRect.bottom - elRect.top }); + } + var elOffset = isBody ? { top: 0, left: 0 } : $element.offset(); + var scroll = { scroll: isBody ? document.documentElement.scrollTop || document.body.scrollTop : $element.scrollTop() }; + var outerDims = isBody ? { width: $(window).width(), height: $(window).height() } : null; + + return $.extend({}, elRect, scroll, outerDims, elOffset); + }; + + Tooltip.prototype.getCalculatedOffset = function (placement, pos, actualWidth, actualHeight) { + return placement == 'bottom' ? { top: pos.top + pos.height, left: pos.left + pos.width / 2 - actualWidth / 2 } : + placement == 'top' ? { top: pos.top - actualHeight, left: pos.left + pos.width / 2 - actualWidth / 2 } : + placement == 'left' ? { top: pos.top + pos.height / 2 - actualHeight / 2, left: pos.left - actualWidth } : + /* placement == 'right' */ { top: pos.top + pos.height / 2 - actualHeight / 2, left: pos.left + pos.width }; + + }; + + Tooltip.prototype.getViewportAdjustedDelta = function (placement, pos, actualWidth, actualHeight) { + var delta = { top: 0, left: 0 }; + if (!this.$viewport) return delta; + + var viewportPadding = this.options.viewport && this.options.viewport.padding || 0; + var viewportDimensions = this.getPosition(this.$viewport); + + if (/right|left/.test(placement)) { + var topEdgeOffset = pos.top - viewportPadding - viewportDimensions.scroll; + var bottomEdgeOffset = pos.top + viewportPadding - viewportDimensions.scroll + actualHeight; + if (topEdgeOffset < viewportDimensions.top) { // top overflow + delta.top = viewportDimensions.top - topEdgeOffset; + } else if (bottomEdgeOffset > viewportDimensions.top + viewportDimensions.height) { // bottom overflow + delta.top = viewportDimensions.top + viewportDimensions.height - bottomEdgeOffset; + } + } else { + var leftEdgeOffset = pos.left - viewportPadding; + var rightEdgeOffset = pos.left + viewportPadding + actualWidth; + if (leftEdgeOffset < viewportDimensions.left) { // left overflow + delta.left = viewportDimensions.left - leftEdgeOffset; + } else if (rightEdgeOffset > viewportDimensions.right) { // right overflow + delta.left = viewportDimensions.left + viewportDimensions.width - rightEdgeOffset; + } + } + + return delta; + }; + + Tooltip.prototype.getTitle = function () { + var title; + var $e = this.$element; + var o = this.options; + + title = $e.attr('data-original-title') + || (typeof o.title == 'function' ? o.title.call($e[0]) : o.title); + + return title; + }; + + Tooltip.prototype.getUID = function (prefix) { + do prefix += ~~(Math.random() * 1000000); + while (document.getElementById(prefix)); + return prefix; + }; + + Tooltip.prototype.tip = function () { + if (!this.$tip) { + this.$tip = $(this.options.template); + if (this.$tip.length != 1) { + throw new Error(this.type + ' `template` option must consist of exactly 1 top-level element!'); + } + } + return this.$tip; + }; + + Tooltip.prototype.arrow = function () { + return (this.$arrow = this.$arrow || this.tip().find('.tooltip-arrow')); + }; + + Tooltip.prototype.enable = function () { + this.enabled = true; + }; + + Tooltip.prototype.disable = function () { + this.enabled = false; + }; + + Tooltip.prototype.toggleEnabled = function () { + this.enabled = !this.enabled; + }; + + Tooltip.prototype.toggle = function (e) { + var self = this; + if (e) { + self = $(e.currentTarget).data('bs.' + this.type); + if (!self) { + self = new this.constructor(e.currentTarget, this.getDelegateOptions()); + $(e.currentTarget).data('bs.' + this.type, self); + } + } + + if (e) { + self.inState.click = !self.inState.click; + if (self.isInStateTrue()) self.enter(self); + else self.leave(self); + } else { + self.tip().hasClass('in') ? self.leave(self) : self.enter(self); + } + }; + + Tooltip.prototype.destroy = function () { + var that = this; + clearTimeout(this.timeout); + this.hide(function () { + that.$element.off('.' + that.type).removeData('bs.' + that.type); + if (that.$tip) { + that.$tip.detach(); + } + that.$tip = null; + that.$arrow = null; + that.$viewport = null; + }); + }; + + + // TOOLTIP PLUGIN DEFINITION + // ========================= + + function Plugin(option) { + return this.each(function () { + var $this = $(this); + var data = $this.data('bs.tooltip'); + var options = typeof option == 'object' && option; + + if (!data && /destroy|hide/.test(option)) return; + if (!data) $this.data('bs.tooltip', (data = new Tooltip(this, options))); + if (typeof option == 'string') data[option](); + }); + } + + var old = $.fn.tooltip; + + $.fn.tooltip = Plugin; + $.fn.tooltip.Constructor = Tooltip; + + + // TOOLTIP NO CONFLICT + // =================== + + $.fn.tooltip.noConflict = function () { + $.fn.tooltip = old; + return this; + }; + + }(jQuery); + + + /* ======================================================================== + * Bootstrap: popover.js v3.3.5 + * http://getbootstrap.com/javascript/#popovers + * ======================================================================== + * Copyright 2011-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * ======================================================================== */ + + + +function ($) { + 'use strict'; + + // POPOVER PUBLIC CLASS DEFINITION + // =============================== + + var Popover = function (element, options) { + this.init('popover', element, options); + }; + + if (!$.fn.tooltip) throw new Error('Popover requires tooltip.js'); + + Popover.VERSION = '3.3.5'; + + Popover.DEFAULTS = $.extend({}, $.fn.tooltip.Constructor.DEFAULTS, { + placement: 'right', + trigger: 'click', + content: '', + template: '<div class="popover" role="tooltip"><div class="arrow"></div><h3 class="popover-title"></h3><div class="popover-content"></div></div>' + }); + + + // NOTE: POPOVER EXTENDS tooltip.js + // ================================ + + Popover.prototype = $.extend({}, $.fn.tooltip.Constructor.prototype); + + Popover.prototype.constructor = Popover; + + Popover.prototype.getDefaults = function () { + return Popover.DEFAULTS; + }; + + Popover.prototype.setContent = function () { + var $tip = this.tip(); + var title = this.getTitle(); + var content = this.getContent(); + + $tip.find('.popover-title')[this.options.html ? 'html' : 'text'](title); + $tip.find('.popover-content').children().detach().end()[ // we use append for html objects to maintain js events + this.options.html ? (typeof content == 'string' ? 'html' : 'append') : 'text' + ](content); + + $tip.removeClass('fade top bottom left right in'); + + // IE8 doesn't accept hiding via the `:empty` pseudo selector, we have to do + // this manually by checking the contents. + if (!$tip.find('.popover-title').html()) $tip.find('.popover-title').hide(); + }; + + Popover.prototype.hasContent = function () { + return this.getTitle() || this.getContent(); + }; + + Popover.prototype.getContent = function () { + var $e = this.$element; + var o = this.options; + + return $e.attr('data-content') + || (typeof o.content == 'function' ? + o.content.call($e[0]) : + o.content); + }; + + Popover.prototype.arrow = function () { + return (this.$arrow = this.$arrow || this.tip().find('.arrow')); + }; + + + // POPOVER PLUGIN DEFINITION + // ========================= + + function Plugin(option) { + return this.each(function () { + var $this = $(this); + var data = $this.data('bs.popover'); + var options = typeof option == 'object' && option; + + if (!data && /destroy|hide/.test(option)) return; + if (!data) $this.data('bs.popover', (data = new Popover(this, options))); + if (typeof option == 'string') data[option](); + }); + } + + var old = $.fn.popover; + + $.fn.popover = Plugin; + $.fn.popover.Constructor = Popover; + + + // POPOVER NO CONFLICT + // =================== + + $.fn.popover.noConflict = function () { + $.fn.popover = old; + return this; + }; + + }(jQuery); +} diff --git a/priv/static/js/metricsgraphics/common/brush.js b/priv/static/js/metricsgraphics/common/brush.js new file mode 100644 index 0000000..4a4deff --- /dev/null +++ b/priv/static/js/metricsgraphics/common/brush.js @@ -0,0 +1,126 @@ +{ + +const get_extent_rect = args => { + return d3.select(args.target).select('.mg-extent').size() + ? d3.select(args.target).select('.mg-extent') + : d3.select(args.target) + .select('.mg-rollover-rect, .mg-voronoi') + .insert('g', '*') + .classed('mg-brush', true) + .append('rect') + .classed('mg-extent', true); +}; + +const create_brushing_pattern = (args, range) => { + const x = range.x[0]; + const width = range.x[1] - range.x[0]; + const y = range.y[0]; + const height = range.y[1] - range.y[0]; + get_extent_rect(args) + .attr('x', x) + .attr('width', width) + .attr('y', y) + .attr('height', height) + .attr('opacity', 1); +}; + +const remove_brushing_pattern = args => { + get_extent_rect(args) + .attr('width', 0) + .attr('height', 0) + .attr('opacity', 0); +}; + +const add_event_handler_for_brush = (args, target, axis) => { + const svg = d3.select(args.target).select('svg'); + const rollover = svg.select('.mg-rollover-rect, .mg-voronoi'); + const container = rollover.node(); + const targetUid = mg_target_ref(args.target); + let isDragging = false; + let mouseDown = false; + let origin = []; + + const calculateSelectionRange = () => { + const min_x = args.left; + const max_x = args.width - args.right - args.buffer; + const min_y = args.top; + const max_y = args.height - args.bottom - args.buffer; + const mouse = d3.mouse(container); + const range = {}; + range.x = axis.x ? [ + Math.max(min_x, Math.min(origin[0], mouse[0])), + Math.min(max_x, Math.max(origin[0], mouse[0])) + ] : [min_x, max_x]; + range.y = axis.y ? [ + Math.max(min_y, Math.min(origin[1], mouse[1])), + Math.min(max_y, Math.max(origin[1], mouse[1])) + ] : [min_y, max_y]; + return range; + }; + + rollover.classed('mg-brush-container', true); + rollover.on('mousedown.' + targetUid, () => { + mouseDown = true; + isDragging = false; + origin = d3.mouse(container); + svg.classed('mg-brushed', false); + svg.classed('mg-brushing-in-progress', true); + remove_brushing_pattern(args); + }); + d3.select(document).on('mousemove.' + targetUid, () => { + if (mouseDown) { + isDragging = true; + rollover.classed('mg-brushing', true); + create_brushing_pattern(args, calculateSelectionRange()); + } + }); + d3.select(document).on('mouseup.' + targetUid, () => { + if (!mouseDown) return; + mouseDown = false; + svg.classed('mg-brushing-in-progress', false); + const range = calculateSelectionRange(); + if (isDragging) { + isDragging = false; + if (target === args) { + MG.zoom_to_data_range(target, range); + if (args.click_to_zoom_out) + svg.select('.mg-rollover-rect, .mg-voronoi').classed('mg-brushed', true); + } else { + const domain = MG.convert_range_to_domain(args, range); + MG.zoom_to_data_domain(target, domain); + } + } else if (args.click_to_zoom_out) { + MG.zoom_to_raw_range(target); + } + if (mg_is_function(args.brushing_selection_changed)) + args.brushing_selection_changed(args, range); + }); +}; + +const add_brush_function = args => { + if (args.x_axis_type === 'categorical' || args.y_axis_type === 'categorical') + return console.warn('The option "brush" does not support axis type "categorical" currently.'); + if (!args.zoom_target) args.zoom_target = args; + if (args.zoom_target !== args) args.zoom_target.processed.subplot = args; + let brush_axis; + switch (args.brush) { + case 'x': + brush_axis = {x: true, y: false}; + break; + case 'y': + brush_axis = {x: false, y: true}; + break; + case 'xy': + brush_axis = {x: true, y: true}; + break; + default: + brush_axis = {x: true, y: true}; + } + add_event_handler_for_brush(args, args.zoom_target, brush_axis); +}; + +MG.add_brush_function = add_brush_function; +MG.create_brushing_pattern = create_brushing_pattern; +MG.remove_brushing_pattern = remove_brushing_pattern; + +} diff --git a/priv/static/js/metricsgraphics/common/chart_title.js b/priv/static/js/metricsgraphics/common/chart_title.js new file mode 100644 index 0000000..b2624dd --- /dev/null +++ b/priv/static/js/metricsgraphics/common/chart_title.js @@ -0,0 +1,67 @@ +function chart_title(args) { + 'use strict'; + + var svg = mg_get_svg_child_of(args.target); + + //remove the current title if it exists + svg.select('.mg-header').remove(); + + if (args.target && args.title) { + var chartTitle = svg.insert('text') + .attr('class', 'mg-header') + .attr('x', args.center_title_full_width ? args.width /2 : (args.width + args.left - args.right) / 2) + .attr('y', args.title_y_position) + .attr('text-anchor', 'middle') + .attr('dy', '0.55em'); + + //show the title + chartTitle.append('tspan') + .attr('class', 'mg-chart-title') + .text(args.title); + + //show and activate the description icon if we have a description + if (args.show_tooltips && args.description && mg_jquery_exists()) { + chartTitle.append('tspan') + .attr('class', 'mg-chart-description') + .attr('dx', '0.3em') + .text('\uf059'); + + //now that the title is an svg text element, we'll have to trigger + //mouseenter, mouseleave events manually for the popover to work properly + var $chartTitle = $(chartTitle.node()); + $chartTitle.popover({ + html: true, + animation: false, + placement: 'top', + content: args.description, + container: args.target, + trigger: 'manual', + template: '<div class="popover mg-popover"><div class="arrow"></div><div class="popover-inner"><h3 class="popover-title"></h3><div class="popover-content"><p></p></div></div></div>' + }).on('mouseenter', function() { + d3.selectAll(args.target) + .selectAll('.mg-popover') + .remove(); + + $(this).popover('show'); + $(d3.select(args.target).select('.popover').node()) + .on('mouseleave', function () { + $chartTitle.popover('hide'); + }); + }).on('mouseleave', function () { + setTimeout(function () { + if (!$('.popover:hover').length) { + $chartTitle.popover('hide'); + } + }, 120); + }); + } else if (args.show_tooltips && args.description && typeof $ === 'undefined') { + args.error = 'In order to enable tooltips, please make sure you include jQuery.'; + } + } + + if (args.error) { + error(args); + } +} + +MG.chart_title = chart_title; diff --git a/priv/static/js/metricsgraphics/common/data_graphic.js b/priv/static/js/metricsgraphics/common/data_graphic.js new file mode 100644 index 0000000..517b3fa --- /dev/null +++ b/priv/static/js/metricsgraphics/common/data_graphic.js @@ -0,0 +1,205 @@ +MG.globals = {}; +MG.deprecations = { + rollover_callback: { replacement: 'mouseover', version: '2.0' }, + rollout_callback: { replacement: 'mouseout', version: '2.0' }, + x_rollover_format: { replacement: 'x_mouseover', version: '2.10' }, + y_rollover_format: { replacement: 'y_mouseover', version: '2.10' }, + show_years: { replacement: 'show_secondary_x_label', version: '2.1' }, + xax_start_at_min: { replacement: 'axes_not_compact', version: '2.7' }, + interpolate_tension: { replacement: 'interpolate', version: '2.10' } +}; +MG.globals.link = false; +MG.globals.version = "1.1"; + +MG.options = { // <name>: [<defaultValue>, <availableType>] + x_axis_type: [null, ['categorical']], // TO BE INTRODUCED IN 2.10 + y_axis_type: [null, ['categorical']], // TO BE INTRODUCED IN 2.10 + y_padding_percentage: [0.05, 'number'], // for categorical scales + y_outer_padding_percentage: [0.1, 'number'], // for categorical scales + ygroup_padding_percentage: [0.25, 'number'], // for categorical scales + ygroup_outer_padding_percentage: [0, 'number'], // for categorical scales + x_padding_percentage: [0.05, 'number'], // for categorical scales + x_outer_padding_percentage: [0.1, 'number'], // for categorical scales + xgroup_padding_percentage: [0.25, 'number'], // for categorical scales + xgroup_outer_padding_percentage: [0, 'number'], // for categorical scales + ygroup_accessor: [null, 'string'], + xgroup_accessor: [null, 'string'], + y_categorical_show_guides: [false, 'boolean'], + x_categorical_show_guide: [false, 'boolean'], + rotate_x_labels: [0, 'number'], + rotate_y_labels: [0, 'number'], + scales: [{}], + scalefns: [{}], + // Data + data: [[], ['object[]', 'number[]']], // the data object + missing_is_zero: [false, 'boolean'], // assume missing observations are zero + missing_is_hidden: [false, 'boolean'], // show missing observations as missing line segments + missing_is_hidden_accessor: [null, 'string'], // the accessor for identifying observations as missing + utc_time: [false, 'boolean'], // determines whether to use a UTC or local time scale + x_accessor: ['date', 'string'], // the data element that's the x-accessor + x_sort: [true, 'boolean'], // determines whether to sort the x-axis' values + y_accessor: ['value', ['string', 'string[]']], // the data element that's the y-accessor + // Axes + axes_not_compact: [true, 'boolean'], // determines whether to draw compact or non-compact axes + european_clock: [false, 'boolean'], // determines whether to show labels using a 24-hour clock + inflator: [10/9, 'number'], // a multiplier for inflating max_x and max_y + max_x: [null, ['number', Date]], // the maximum x-value + max_y: [null, ['number', Date]], // the maximum y-value + min_x: [null, ['number', Date]], // the minimum x-value + min_y: [null, ['number', Date]], // the minimum y-value + min_y_from_data: [false, 'boolean'], // starts y-axis at data's minimum value + show_year_markers: [false, 'boolean'], // determines whether to show year markers along the x-axis + show_secondary_x_label: [true, 'boolean'], // determines whether to show years along the x-axis + small_text: [false, 'boolean'], + x_extended_ticks: [false, 'boolean'], // determines whether to extend the x-axis ticks across the chart + x_axis: [true, 'boolean'], // determines whether to display the x-axis + x_label: ['', 'string'], // the label to show below the x-axis + xax_count: [6, 'number'], // the number of x-axis ticks + xax_format: [null, 'function'], // a function that formats the x-axis' labels + xax_tick_length: [5, 'number'], // the x-axis' tick length in pixels + xax_units: ['', 'string'], // a prefix symbol to be shown alongside the x-axis' labels + x_scale_type: ['linear', 'log'], // the x-axis scale type + y_axis: [true, 'boolean'], // determines whether to display the y-axis + x_axis_position: ['bottom'], // string + y_axis_position: ['left'], // string + y_extended_ticks: [false, 'boolean'], // determines whether to extend the y-axis ticks across the chart + y_label: ['', 'string'], // the label to show beside the y-axis + y_scale_type: ['linear', ['linear', 'log']], // the y-axis scale type + yax_count: [3, 'number'], // the number of y-axis ticks + yax_format: [null, 'function'], // a function that formats the y-axis' labels + yax_tick_length: [5, 'number'], // the y-axis' tick length in pixels + yax_units: ['', 'string'], // a prefix symbol to be shown alongside the y-axis' labels + yax_units_append: [false, 'boolean'], // determines whether to append rather than prepend units + // GraphicOptions + aggregate_rollover: [false, 'boolean'], // links the lines in a multi-line graphic + animate_on_load: [false, 'boolean'], // determines whether lines are transitioned on first-load + area: [true, ['boolean', 'array']], // determines whether to fill the area below the line + flip_area_under_y_value: [null, 'number'], // Specify a Y baseline number value to flip area under it + baselines: [null, 'object[]'], // horizontal lines that indicate, say, goals. + chart_type: ['line', ['line', 'histogram', 'point', 'bar', 'missing-data']], // '{line, histogram, point, bar, missing-data}'], + color: [null, ['string', 'string[]']], + colors: [null, ['string', 'string[]']], + custom_line_color_map: [[], 'number[]'], // maps an arbitrary set of lines to colors + decimals: [2, 'number'], // the number of decimals to show in a rollover + error: ['', 'string'], // does the graphic have an error that we want to communicate to users + format: ['count', ['count', 'percentage']], // the format of the data object (count or percentage) + full_height: [false, 'boolean'], // sets height to that of the parent, adjusts dimensions on window resize + full_width: [false, 'boolean'], // sets width to that of the parent, adjusts dimensions on window resize + interpolate: [d3.curveCatmullRom.alpha(0), [d3.curveBasisClosed, d3.curveBasisOpen, d3.curveBasis, d3.curveBundle, d3.curveCardinalClosed, d3.curveCardinalOpen, d3.curveCardinal, d3.curveCatmullRomClosed, d3.curveCatmullRomOpen, d3.curveLinearClosed, d3.curveLinear, d3.curveMonotoneX, d3.curveMonotoneY, d3.curveNatural, d3.curveStep, d3.curveStepAfter, d3.curveStepBefore]], // the interpolation function to use for rendering lines + legend: ['', 'string[]'], // an array of literals used to label lines + legend_target: ['', 'string'], // the DOM element to insert the legend in + linked: [false, 'boolean'], // used to link multiple graphics together + linked_format: ['%Y-%m-%d', 'string'], // specifies the format of linked rollovers + list: [false, 'boolean'], // automatically maps the data to x and y accessors + markers: [null, 'object[]'], // vertical lines that indicate, say, milestones + max_data_size: [null, 'number'], // for use with custom_line_color_map + missing_text: [null, 'string'], // The text to display for missing graphics + show_missing_background: [true, 'boolean'], // Displays a background for missing graphics + mousemove_align: ['right', 'string'], // implemented in point.js + x_mouseover: [null, ['string', 'function']], + y_mouseover: [null, ['string', 'function']], + mouseover: [null, 'function'], // custom rollover function + mousemove: [null, 'function'], // custom rollover function + mouseout: [null, 'function'], // custom rollover function + click: [null, 'function'], + point_size: [2.5, 'number'], // the radius of the dots in the scatterplot + active_point_on_lines: [false, 'boolean'], // if set, active dot on lines will be displayed. + active_point_accessor: ['active', 'string'], // data accessor value to determine if a point is active or not + active_point_size: [2, 'number'], // the size of the dot that appears on a line when + points_always_visible: [false, 'boolean'], // whether to always display data points and not just on hover + rollover_time_format: [null, 'string'], // custom time format for rollovers + show_confidence_band: [null, 'string[]'], // determines whether to show a confidence band + show_rollover_text: [true, 'boolean'], // determines whether to show text for a data point on rollover + show_tooltips: [true, 'boolean'], // determines whether to display descriptions in tooltips + showActivePoint: [true, 'boolean'], // If enabled show active data point information in chart + target: ['#viz', ['string', HTMLElement]], // the DOM element to insert the graphic in + transition_on_update: [true, 'boolean'], // gracefully transitions the lines on data change + x_rug: [false, 'boolean'], // show a rug plot along the x-axis + y_rug: [false, 'boolean'], // show a rug plot along the y-axis + mouseover_align: ['right', ['right', 'left']], + brush: [null, ['xy','x','y']], // add brush function + brushing_selection_changed: [null, 'function'], // callback function on brushing. the first parameter are the arguments that correspond to this chart, the second parameter is the range of the selection + zoom_target: [null, 'object'], // the zooming target of brushing function + click_to_zoom_out: [true, 'boolean'], // if true and the graph is currently zoomed in, clicking on the graph will zoom out + // Layout + buffer: [8, 'number'], // the padding around the graphic + bottom: [45, 'number'], // the size of the bottom margin + center_title_full_width: [false, 'boolean'], // center title over entire graph + height: [220, 'number'], // the graphic's height + left: [50, 'number'], // the size of the left margin + right: [10, 'number'], // the size of the right margin + small_height_threshold: [120, 'number'], // maximum height for a small graphic + small_width_threshold: [160, 'number'], // maximum width for a small graphic + top: [65, 'number'], // the size of the top margin + width: [350, 'number'], // the graphic's width + title_y_position: [10, 'number'], // how many pixels from the top edge (0) should we show the title at + title: [null, 'string'], + description: [null, 'string'] +}; + +MG.charts = {}; + +MG.defaults = options_to_defaults(MG.options); + +MG.data_graphic = function(args) { + 'use strict'; + + MG.call_hook('global.defaults', MG.defaults); + + if (!args) { args = {}; } + + for (let key in args) { + if (!mg_validate_option(key, args[key])) { + if (!(key in MG.options)) { + console.warn(`Option ${key} not recognized`); + } else { + console.warn(`Option ${key} expected type ${MG.options[key][1]} but got ${args[key]} instead`); + } + } + } + + var selected_chart = MG.charts[args.chart_type || MG.defaults.chart_type]; + merge_with_defaults(args, selected_chart.defaults, MG.defaults); + + if (args.list) { + args.x_accessor = 0; + args.y_accessor = 1; + } + + // check for deprecated parameters + for (var key in MG.deprecations) { + if (args.hasOwnProperty(key)) { + var deprecation = MG.deprecations[key], + message = 'Use of `args.' + key + '` has been deprecated', + replacement = deprecation.replacement, + version; + + // transparently alias the deprecated + if (replacement) { + if (args[replacement]) { + message += '. The replacement - `args.' + replacement + '` - has already been defined. This definition will be discarded.'; + } else { + args[replacement] = args[key]; + } + } + + if (deprecation.warned) { + continue; + } + + deprecation.warned = true; + + if (replacement) { + message += ' in favor of `args.' + replacement + '`'; + } + + warn_deprecation(message, deprecation.version); + } + } + + MG.call_hook('global.before_init', args); + + new selected_chart.descriptor(args); + + return args.data; +}; diff --git a/priv/static/js/metricsgraphics/common/hooks.js b/priv/static/js/metricsgraphics/common/hooks.js new file mode 100644 index 0000000..5f2adb5 --- /dev/null +++ b/priv/static/js/metricsgraphics/common/hooks.js @@ -0,0 +1,63 @@ +/** + Record of all registered hooks. + For internal use only. +*/ +MG._hooks = {}; + +/** + Add a hook callthrough to the stack. + + Hooks are executed in the order that they were registered. +*/ +MG.add_hook = function(name, func, context) { + var hooks; + + if (!MG._hooks[name]) { + MG._hooks[name] = []; + } + + hooks = MG._hooks[name]; + + var already_registered = + hooks.filter(function(hook) { + return hook.func === func; + }) + .length > 0; + + if (already_registered) { + throw 'That function is already registered.'; + } + + hooks.push({ + func: func, + context: context + }); +}; + +/** + Execute registered hooks. + + Optional arguments +*/ +MG.call_hook = function(name) { + var hooks = MG._hooks[name], + result = [].slice.apply(arguments, [1]), + processed; + + if (hooks) { + hooks.forEach(function(hook) { + if (hook.func) { + var params = processed || result; + + if (params && params.constructor !== Array) { + params = [params]; + } + + params = [].concat.apply([], params); + processed = hook.func.apply(hook.context, params); + } + }); + } + + return processed || result; +}; diff --git a/priv/static/js/metricsgraphics/common/init.js b/priv/static/js/metricsgraphics/common/init.js new file mode 100644 index 0000000..4b43e48 --- /dev/null +++ b/priv/static/js/metricsgraphics/common/init.js @@ -0,0 +1,273 @@ +function mg_merge_args_with_defaults(args) { + var defaults = { + target: null, + title: null, + description: null + }; + + if (!args) { + args = {}; + } + + if (!args.processed) { + args.processed = {}; + } + + args = merge_with_defaults(args, defaults); + return args; +} + +function mg_is_time_series(args) { + var first_elem = mg_flatten_array(args.processed.original_data || args.data)[0]; + args.time_series = mg_is_date(first_elem[args.processed.original_x_accessor || args.x_accessor]); +} + +function mg_init_compute_width(args) { + var svg_width = parseInt(args.width); + if (args.full_width) { + svg_width = get_width(args.target); + } + if (args.x_axis_type === 'categorical' && svg_width === null) { + svg_width = mg_categorical_calculate_height(args, 'x'); + } + + args.width = svg_width; +} + +function mg_init_compute_height(args) { + var svg_height = parseInt(args.height); + if (args.full_height) { + svg_height = get_height(args.target); + } + if (args.y_axis_type === 'categorical' && svg_height === null) { + svg_height = mg_categorical_calculate_height(args, 'y'); + } + + args.height = svg_height; +} + +function mg_remove_svg_if_chart_type_has_changed(svg, args) { + if ((!svg.selectAll('.mg-main-line').empty() && args.chart_type !== 'line') || + (!svg.selectAll('.mg-points').empty() && args.chart_type !== 'point') || + (!svg.selectAll('.mg-histogram').empty() && args.chart_type !== 'histogram') || + (!svg.selectAll('.mg-barplot').empty() && args.chart_type !== 'bar') + ) { + svg.remove(); + } +} + +function mg_add_svg_if_it_doesnt_exist(svg, args) { + if (mg_get_svg_child_of(args.target).empty()) { + svg = d3.select(args.target) + .append('svg') + .classed('linked', args.linked) + .attr('width', args.width) + .attr('height', args.height); + } + return svg; +} + +function mg_add_clip_path_for_plot_area(svg, args) { + svg.selectAll('.mg-clip-path').remove(); + svg.append('defs') + .attr('class', 'mg-clip-path') + .append('clipPath') + .attr('id', 'mg-plot-window-' + mg_target_ref(args.target)) + .append('svg:rect') + .attr('x', mg_get_left(args)) + .attr('y', mg_get_top(args)) + .attr('width', args.width - args.left - args.right - args.buffer) + .attr('height', args.height - args.top - args.bottom - args.buffer + 1); +} + +function mg_adjust_width_and_height_if_changed(svg, args) { + if (args.width !== Number(svg.attr('width'))) { + svg.attr('width', args.width); + } + if (args.height !== Number(svg.attr('height'))) { + svg.attr('height', args.height); + } +} + +function mg_set_viewbox_for_scaling(svg, args) { + // we need to reconsider how we handle automatic scaling + svg.attr('viewBox', '0 0 ' + args.width + ' ' + args.height); + if (args.full_width || args.full_height) { + svg.attr('preserveAspectRatio', 'xMinYMin meet'); + } +} + +function mg_remove_missing_classes_and_text(svg) { + // remove missing class + svg.classed('mg-missing', false); + + // remove missing text + svg.selectAll('.mg-missing-text').remove(); + svg.selectAll('.mg-missing-pane').remove(); +} + +function mg_remove_outdated_lines(svg, args) { + // if we're updating an existing chart and we have fewer lines than + // before, remove the outdated lines, e.g. if we had 3 lines, and we're calling + // data_graphic() on the same target with 2 lines, remove the 3rd line + + var i = 0; + + if (svg.selectAll('.mg-main-line').nodes().length >= args.data.length) { + // now, the thing is we can't just remove, say, line3 if we have a custom + // line-color map, instead, see which are the lines to be removed, and delete those + if (args.custom_line_color_map.length > 0) { + var array_full_series = function(len) { + var arr = new Array(len); + for (var i = 0; i < arr.length; i++) { arr[i] = i + 1; } + return arr; + }; + + // get an array of lines ids to remove + var lines_to_remove = arr_diff( + array_full_series(args.max_data_size), + args.custom_line_color_map); + + for (i = 0; i < lines_to_remove.length; i++) { + svg.selectAll('.mg-main-line.mg-line' + lines_to_remove[i] + '-color') + .remove(); + } + } else { + // if we don't have a custom line-color map, just remove the lines from the end + var num_of_new = args.data.length; + var num_of_existing = (svg.selectAll('.mg-main-line').nodes()) ? svg.selectAll('.mg-main-line').nodes().length : 0; + + for (i = num_of_existing; i > num_of_new; i--) { + svg.selectAll('.mg-main-line.mg-line' + i + '-color') + .remove(); + } + } + } +} + +function mg_raise_container_error(container, args) { + if (container.empty()) { + console.warn('The specified target element "' + args.target + '" could not be found in the page. The chart will not be rendered.'); + return; + } +} + +function categoricalInitialization(args, ns) { + var which = ns === 'x' ? args.width : args.height; + mg_categorical_count_number_of_groups(args, ns); + mg_categorical_count_number_of_lanes(args, ns); + mg_categorical_calculate_group_length(args, ns, which); + if (which) mg_categorical_calculate_bar_thickness(args, ns); +} + +function selectXaxFormat(args) { + var c = args.chart_type; + if (!args.processed.xax_format) { + if (args.xax_format) { + args.processed.xax_format = args.xax_format; + } else { + if (c === 'line' || c === 'point' || c === 'histogram') { + args.processed.xax_format = mg_default_xax_format(args); + } else if (c === 'bar') { + args.processed.xax_format = mg_default_bar_xax_format(args); + } + } + } +} + +function mg_categorical_count_number_of_groups(args, ns) { + var accessor_string = ns + 'group_accessor'; + var accessor = args[accessor_string]; + args.categorical_groups = []; + if (accessor) { + var data = args.data[0]; + args.categorical_groups = d3.set(data.map(function(d) { + return d[accessor]; })).values(); + } +} + +function mg_categorical_count_number_of_lanes(args, ns) { + var accessor_string = ns + 'group_accessor'; + var groupAccessor = args[accessor_string]; + + args.total_bars = args.data[0].length; + if (groupAccessor) { + var group_bars = count_array_elements(pluck(args.data[0], groupAccessor)); + group_bars = d3.max(Object.keys(group_bars).map(function(d) { + return group_bars[d]; })); + args.bars_per_group = group_bars; + } else { + args.bars_per_group = args.data[0].length; + } +} + +function mg_categorical_calculate_group_length(args, ns, which) { + var groupHeight = ns + 'group_height'; + if (which) { + var gh = ns === 'y' ? + (args.height - args.top - args.bottom - args.buffer * 2) / (args.categorical_groups.length || 1) : + (args.width - args.left - args.right - args.buffer * 2) / (args.categorical_groups.length || 1); + + args[groupHeight] = gh; + } else { + var step = (1 + args[ns + '_padding_percentage']) * args.bar_thickness; + args[groupHeight] = args.bars_per_group * step + args[ns + '_outer_padding_percentage'] * 2 * step; //args.bar_thickness + (((args.bars_per_group-1) * args.bar_thickness) * (args.bar_padding_percentage + args.bar_outer_padding_percentage*2)); + } +} + +function mg_categorical_calculate_bar_thickness(args, ns) { + // take one group height. + var step = (args[ns + 'group_height']) / (args.bars_per_group + args[ns + '_outer_padding_percentage']); + args.bar_thickness = step - (step * args[ns + '_padding_percentage']); +} + +function mg_categorical_calculate_height(args, ns) { + var groupContribution = (args[ns + 'group_height']) * (args.categorical_groups.length || 1); + + var marginContribution = ns === 'y' + ? args.top + args.bottom + args.buffer * 2 + : args.left + args.right + args.buffer * 2; + + return groupContribution + marginContribution + + (args.categorical_groups.length * args[ns + 'group_height'] * (args[ns + 'group_padding_percentage'] + args[ns + 'group_outer_padding_percentage'])); +} + +function mg_barchart_extrapolate_group_and_thickness_from_height(args) { + // we need to set args.bar_thickness, group_height +} + +function init(args) { + 'use strict'; + args = arguments[0]; + args = mg_merge_args_with_defaults(args); + // If you pass in a dom element for args.target, the expectation + // of a string elsewhere will break. + var container = d3.select(args.target); + mg_raise_container_error(container, args); + + var svg = container.selectAll('svg'); + + // some things that will need to be calculated if we have a categorical axis. + if (args.y_axis_type === 'categorical') { categoricalInitialization(args, 'y'); } + if (args.x_axis_type === 'categorical') { categoricalInitialization(args, 'x'); } + + selectXaxFormat(args); + + mg_is_time_series(args); + mg_init_compute_width(args); + mg_init_compute_height(args); + + mg_remove_svg_if_chart_type_has_changed(svg, args); + svg = mg_add_svg_if_it_doesnt_exist(svg, args); + + mg_add_clip_path_for_plot_area(svg, args); + mg_adjust_width_and_height_if_changed(svg, args); + mg_set_viewbox_for_scaling(svg, args); + mg_remove_missing_classes_and_text(svg); + chart_title(args); + mg_remove_outdated_lines(svg, args); + + return this; +} + +MG.init = init; diff --git a/priv/static/js/metricsgraphics/common/markers.js b/priv/static/js/metricsgraphics/common/markers.js new file mode 100644 index 0000000..4371282 --- /dev/null +++ b/priv/static/js/metricsgraphics/common/markers.js @@ -0,0 +1,132 @@ +function mg_return_label(d) { + return d.label; +} + +function mg_remove_existing_markers(svg) { + svg.selectAll('.mg-markers').remove(); + svg.selectAll('.mg-baselines').remove(); +} + +function mg_in_range(args) { + return function(d) { + return (args.scales.X(d[args.x_accessor]) >= mg_get_plot_left(args)) && (args.scales.X(d[args.x_accessor]) <= mg_get_plot_right(args)); + }; +} + +function mg_x_position(args) { + return function(d) { + return args.scales.X(d[args.x_accessor]); + }; +} + +function mg_x_position_fixed(args) { + var _mg_x_pos = mg_x_position(args); + return function(d) { + return _mg_x_pos(d).toFixed(2); + }; +} + +function mg_y_position_fixed(args) { + var _mg_y_pos = args.scales.Y; + return function(d) { + return _mg_y_pos(d.value).toFixed(2); + }; +} + +function mg_place_annotations(checker, class_name, args, svg, line_fcn, text_fcn) { + var g; + if (checker) { + g = svg.append('g').attr('class', class_name); + line_fcn(g, args); + text_fcn(g, args); + } +} + +function mg_place_markers(args, svg) { + mg_place_annotations(args.markers, 'mg-markers', args, svg, mg_place_marker_lines, mg_place_marker_text); +} + +function mg_place_baselines(args, svg) { + mg_place_annotations(args.baselines, 'mg-baselines', args, svg, mg_place_baseline_lines, mg_place_baseline_text); +} + +function mg_place_marker_lines(gm, args) { + var x_pos_fixed = mg_x_position_fixed(args); + gm.selectAll('.mg-markers') + .data(args.markers.filter(mg_in_range(args))) + .enter() + .append('line') + .attr('x1', x_pos_fixed) + .attr('x2', x_pos_fixed) + .attr('y1', args.top) + .attr('y2', mg_get_plot_bottom(args)) + .attr('class', function(d) { + return d.lineclass; + }) + .attr('stroke-dasharray', '3,1'); +} + +function mg_place_marker_text(gm, args) { + gm.selectAll('.mg-markers') + .data(args.markers.filter(mg_in_range(args))) + .enter() + .append('text') + .attr('class', function(d) { + return d.textclass || ''; }) + .classed('mg-marker-text', true) + .attr('x', mg_x_position(args)) + .attr('y', args.x_axis_position === 'bottom' ? mg_get_top(args) * 0.95 : mg_get_bottom(args) + args.buffer) + .attr('text-anchor', 'middle') + .text(mg_return_label) + .each(function(d) { + if (d.click) { + d3.select(this).style('cursor', 'pointer') + .on('click', d.click); + } + if (d.mouseover) { + d3.select(this).style('cursor', 'pointer') + .on('mouseover', d.mouseover); + } + if (d.mouseout) { + d3.select(this).style('cursor', 'pointer') + .on('mouseout', d.mouseout); + } + }); + + mg_prevent_horizontal_overlap(gm.selectAll('.mg-marker-text').nodes(), args); +} + +function mg_place_baseline_lines(gb, args) { + var y_pos = mg_y_position_fixed(args); + gb.selectAll('.mg-baselines') + .data(args.baselines) + .enter().append('line') + .attr('x1', mg_get_plot_left(args)) + .attr('x2', mg_get_plot_right(args)) + .attr('y1', y_pos) + .attr('y2', y_pos); +} + +function mg_place_baseline_text(gb, args) { + var y_pos = mg_y_position_fixed(args); + gb.selectAll('.mg-baselines') + .data(args.baselines) + .enter().append('text') + .attr('x', mg_get_plot_right(args)) + .attr('y', y_pos) + .attr('dy', -3) + .attr('text-anchor', 'end') + .text(mg_return_label); +} + +function markers(args) { + 'use strict'; + + var svg = mg_get_svg_child_of(args.target); + mg_remove_existing_markers(svg); + mg_place_markers(args, svg); + mg_place_baselines(args, svg); + return this; +} + +MG.markers = markers; diff --git a/priv/static/js/metricsgraphics/common/register.js b/priv/static/js/metricsgraphics/common/register.js new file mode 100644 index 0000000..032736f --- /dev/null +++ b/priv/static/js/metricsgraphics/common/register.js @@ -0,0 +1,16 @@ +function register(chartType, descriptor, options) { + const defaults = options ? options_to_defaults(options) : {}; + MG.charts[chartType] = { + descriptor: descriptor, + defaults: defaults, + }; + if (options) { + Object.keys(options).map(key => { + if (!(key in MG.options)) { + MG.options[key] = options[key]; + } + }); + } +} + +MG.register = register; diff --git a/priv/static/js/metricsgraphics/common/rollover.js b/priv/static/js/metricsgraphics/common/rollover.js new file mode 100644 index 0000000..c462d98 --- /dev/null +++ b/priv/static/js/metricsgraphics/common/rollover.js @@ -0,0 +1,98 @@ +function mg_clear_mouseover_container(svg) { + svg.selectAll('.mg-active-datapoint-container').selectAll('*').remove(); +} + +function mg_setup_mouseover_container(svg, args) { + svg.select('.mg-active-datapoint').remove(); + var text_anchor = args.mouseover_align === 'right' + ? 'end' + : (args.mouseover_align === 'left' + ? 'start' + : 'middle'); + + var mouseover_x = (args.mouseover_align === 'right') + ? mg_get_plot_right(args) + : (args.mouseover_align === 'left' + ? mg_get_plot_left(args) + : (args.width - args.left - args.right) / 2 + args.left); + + var active_datapoint = svg.select('.mg-active-datapoint-container') + .attr('transform', 'translate(0 -18)') + .append('text') + .attr('class', 'mg-active-datapoint') + .attr('xml:space', 'preserve') + .attr('text-anchor', text_anchor); + + // set the rollover text's position; if we have markers on two lines, + // nudge up the rollover text a bit + var active_datapoint_y_nudge = 0.75; + + var y_position = (args.x_axis_position === 'bottom') + ? mg_get_top(args) * active_datapoint_y_nudge + : mg_get_bottom(args) + args.buffer * 3; + + if (args.markers) { + var yPos; + svg.selectAll('.mg-marker-text') + .each(function() { + if (!yPos) { + yPos = d3.select(this).attr('y'); + } else if (yPos !== d3.select(this).attr('y')) { + active_datapoint_y_nudge = 0.56; + } + }); + } + + active_datapoint + .attr('transform', 'translate(' + mouseover_x + ',' + (y_position) + ')'); +} + +function mg_mouseover_tspan(svg, text) { + let tspan = svg.append('tspan').text(text); + + return { + bold: () => tspan.attr('font-weight', 'bold'), + font_size: (pts) => tspan.attr('font-size', pts), + x: (x) => tspan.attr('x', x), + y: (y) => tspan.attr('y', y), + elem: tspan + }; +} + +function mg_reset_text_container(svg) { + var textContainer = svg.select('.mg-active-datapoint'); + textContainer + .selectAll('*') + .remove(); + return textContainer; +} + +function mg_mouseover_row(row_number, container, rargs) { + var lineHeight = 1.1; + var rrr = container.append('tspan') + .attr('x', 0) + .attr('y', (row_number * lineHeight) + 'em'); + + return { + rargs, + text: (text) => { + return mg_mouseover_tspan(rrr, text); + } + }; +} + +function mg_mouseover_text(args, rargs) { + mg_setup_mouseover_container(rargs.svg, args); + + let mouseOver = { + row_number: 0, + rargs, + mouseover_row: (rargs) => { + mouseOver.row_number += 1; + return mg_mouseover_row(mouseOver.row_number, mouseOver.text_container, rargs); + }, + text_container: mg_reset_text_container(rargs.svg) + }; + + return mouseOver; +} diff --git a/priv/static/js/metricsgraphics/common/scales.js b/priv/static/js/metricsgraphics/common/scales.js new file mode 100644 index 0000000..4e76285 --- /dev/null +++ b/priv/static/js/metricsgraphics/common/scales.js @@ -0,0 +1,340 @@ +function mg_add_scale_function(args, scalefcn_name, scale, accessor, inflation) { + args.scalefns[scalefcn_name] = function(di) { + if (inflation === undefined) return args.scales[scale](di[accessor]); + else return args.scales[scale](di[accessor]) + inflation; + }; +} + +function mg_position(str, args) { + if (str === 'bottom' || str === 'top') { + return [mg_get_plot_left(args), mg_get_plot_right(args)]; + } + + if (str === 'left' || str === 'right') { + return [mg_get_plot_bottom(args), args.top]; + } +} + +function mg_cat_position(str, args) { + if (str === 'bottom' || str === 'top') { + return [mg_get_plot_left(args), mg_get_plot_right(args)]; + } + + if (str === 'left' || str === 'right') { + return [mg_get_plot_bottom(args), mg_get_plot_top(args)]; + } +} + +function MGScale(args) { + // big wrapper around d3 scale that automatically formats & calculates scale bounds + // according to the data, and handles other niceties. + var scaleArgs = {}; + scaleArgs.use_inflator = false; + scaleArgs.zero_bottom = false; + scaleArgs.scaleType = 'numerical'; + + this.namespace = function(_namespace) { + scaleArgs.namespace = _namespace; + scaleArgs.namespace_accessor_name = scaleArgs.namespace + '_accessor'; + scaleArgs.scale_name = scaleArgs.namespace.toUpperCase(); + scaleArgs.scalefn_name = scaleArgs.namespace + 'f'; + return this; + }; + + this.scaleName = function(scaleName) { + scaleArgs.scale_name = scaleName.toUpperCase(); + scaleArgs.scalefn_name = scaleName +'f'; + return this; + }; + + this.inflateDomain = function(tf) { + scaleArgs.use_inflator = tf; + return this; + }; + + this.zeroBottom = function(tf) { + scaleArgs.zero_bottom = tf; + return this; + }; + + ///////////////////////////////////////////////////////////////////////////////////////////////////////// + /// all scale domains are either numerical (number, date, etc.) or categorical (factor, label, etc) ///// + ///////////////////////////////////////////////////////////////////////////////////////////////////////// + // these functions automatically create the d3 scale function and place the domain. + + this.numericalDomainFromData = function() { + var other_flat_data_arrays = []; + + if (arguments.length > 0) { + other_flat_data_arrays = arguments; + } + + // pull out a non-empty array in args.data. + var illustrative_data; + for (var i = 0; i < args.data.length; i++) { + if (args.data[i].length > 0) { + illustrative_data = args.data[i]; + } + } + scaleArgs.is_time_series = mg_is_date(illustrative_data[0][args[scaleArgs.namespace_accessor_name]]) + ? true + : false; + + mg_add_scale_function(args, scaleArgs.scalefn_name, scaleArgs.scale_name, args[scaleArgs.namespace_accessor_name]); + + mg_min_max_numerical(args, scaleArgs, other_flat_data_arrays, scaleArgs.use_inflator); + + var time_scale = (args.utc_time) + ? d3.scaleUtc() + : d3.scaleTime(); + + args.scales[scaleArgs.scale_name] = (scaleArgs.is_time_series) + ? time_scale + : (mg_is_function(args[scaleArgs.namespace + '_scale_type'])) + ? args.y_scale_type() + : (args[scaleArgs.namespace + '_scale_type'] === 'log') + ? d3.scaleLog() + : d3.scaleLinear(); + + args.scales[scaleArgs.scale_name].domain([args.processed['min_' + scaleArgs.namespace], args.processed['max_' + scaleArgs.namespace]]); + scaleArgs.scaleType = 'numerical'; + + return this; + }; + + this.categoricalDomain = function(domain) { + args.scales[scaleArgs.scale_name] = d3.scaleOrdinal().domain(domain); + mg_add_scale_function(args, scaleArgs.scalefn_name, scaleArgs.scale_name, args[scaleArgs.namespace_accessor_name]); + return this; + }; + + this.categoricalDomainFromData = function() { + // make args.categorical_variables. + // lets make the categorical variables. + var all_data = mg_flatten_array(args.data); + //d3.set(data.map(function(d){return d[args.group_accessor]})).values() + scaleArgs.categoricalVariables = d3.set(all_data.map(function(d) { + return d[args[scaleArgs.namespace_accessor_name]]; })).values(); + args.scales[scaleArgs.scale_name] = d3.scaleBand() + .domain(scaleArgs.categoricalVariables); + + scaleArgs.scaleType = 'categorical'; + return this; + }; + + ///////////////////////////////////////////////////////////////////////////////////////////////////////////// + ////////// all scale ranges are either positional (for axes, etc) or arbitrary (colors, size, etc) ////////// + ///////////////////////////////////////////////////////////////////////////////////////////////////////////// + + this.numericalRange = function(range) { + if (typeof range === 'string') { + args + .scales[scaleArgs.scale_name] + .range(mg_position(range, args)); + } else { + args + .scales[scaleArgs.scale_name] + .range(range); + } + + return this; + }; + + this.categoricalRangeBands = function(range, halfway) { + if (halfway === undefined) halfway = false; + + var namespace = scaleArgs.namespace; + var paddingPercentage = args[namespace + '_padding_percentage']; + var outerPaddingPercentage = args[namespace + '_outer_padding_percentage']; + if (typeof range === 'string') { + // if string, it's a location. Place it accordingly. + args.scales[scaleArgs.scale_name] + .range(mg_position(range, args)) + .paddingInner(paddingPercentage) + .paddingOuter(outerPaddingPercentage); + } else { + args.scales[scaleArgs.scale_name] + .range(range) + .paddingInner(paddingPercentage) + .paddingOuter(outerPaddingPercentage); + } + + mg_add_scale_function( + args, + scaleArgs.scalefn_name, + scaleArgs.scale_name, + args[scaleArgs.namespace_accessor_name], + halfway + ? args.scales[scaleArgs.scale_name].bandwidth() / 2 + : 0 + ); + + return this; + }; + + this.categoricalRange = function(range) { + args.scales[scaleArgs.scale_name].range(range); + mg_add_scale_function(args, scaleArgs.scalefn_name, scaleArgs.scale_name, args[scaleArgs.namespace_accessor_name]); + return this; + }; + + this.categoricalColorRange = function() { + args.scales[scaleArgs.scale_name] = args.scales[scaleArgs.scale_name].domain().length > 10 + ? d3.scaleOrdinal(d3.schemeCategory20) + : d3.scaleOrdinal(d3.schemeCategory10); + + args + .scales[scaleArgs.scale_name] + .domain(scaleArgs.categoricalVariables); + + mg_add_scale_function(args, scaleArgs.scalefn_name, scaleArgs.scale_name, args[scaleArgs.namespace_accessor_name]); + return this; + }; + + this.clamp = function(yn) { + args.scales[scaleArgs.scale_name].clamp(yn); + return this; + }; + + return this; +} + +MG.scale_factory = MGScale; + +/////////////////////////////// x, x_accessor, markers, baselines, etc. +function mg_min_max_numerical(args, scaleArgs, additional_data_arrays) { + // A BIT OF EXPLANATION ABOUT THIS FUNCTION + // This function pulls out all the accessor values in all the arrays in args.data. + // We also have this additional argument, additional_data_arrays, which is an array of arrays of raw data values. + // These values also get concatenated to the data pulled from args.data, and the extents are calculate from that. + // They are optional. + // + // This may seem arbitrary, but it gives us a lot of flexibility. For instance, if we're calculating + // the min and max for the y axis of a line chart, we're going to want to also factor in baselines (horizontal lines + // that might potentially be outside of the y value bounds). The easiest way to do this is in the line.js code + // & scale creation to just flatten the args.baselines array, pull out hte values, and feed it in + // so it appears in additional_data_arrays. + var namespace = scaleArgs.namespace; + var namespace_accessor_name = scaleArgs.namespace_accessor_name; + var use_inflator = scaleArgs.use_inflator; + var zero_bottom = scaleArgs.zero_bottom; + + var accessor = args[namespace_accessor_name]; + + // add together all relevant data arrays. + var all_data = mg_flatten_array(args.data) + .map(function(dp) { + return dp[accessor]; }) + .concat(mg_flatten_array(additional_data_arrays)); + + // do processing for log + if (args[namespace + '_scale_type'] === 'log') { + all_data = all_data.filter(function(d) { + return d > 0; + }); + } + + // use inflator? + var extents = d3.extent(all_data); + var min_val = extents[0]; + var max_val = extents[1]; + + // bolt scale domain to zero when the right conditions are met: + // not pulling the bottom of the range from data + // not zero-bottomed + // not a time series + if (zero_bottom && !args['min_' + namespace + '_from_data'] && min_val > 0 && !scaleArgs.is_time_series) { + min_val = args[namespace + '_scale_type'] === 'log' ? 1 : 0; + } + + if (args[namespace + '_scale_type'] !== 'log' && min_val < 0 && !scaleArgs.is_time_series) { + min_val = min_val - (min_val - min_val * args.inflator) * use_inflator; + } + + if (!scaleArgs.is_time_series) { + max_val = (max_val < 0) ? max_val + (max_val - max_val * args.inflator) * use_inflator : max_val * (use_inflator ? args.inflator : 1); + } + + min_val = args['min_' + namespace] != null ? args['min_' + namespace] : min_val; + max_val = args['max_' + namespace] != null ? args['max_' + namespace] : max_val; + // if there's a single data point, we should custom-set the max values + // so we're displaying some kind of range + if (min_val === max_val && args['min_' + namespace] == null && + args['max_' + namespace] == null) { + if (mg_is_date(min_val)) { + max_val = new Date(MG.clone(min_val).setDate(min_val.getDate() + 1)); + } else if (typeof min_val === 'number') { + max_val = min_val + 1; + mg_force_xax_count_to_be_two(args); + } + } + + args.processed['min_' + namespace] = min_val; + args.processed['max_' + namespace] = max_val; + if (args.processed['zoom_' + namespace]) { + args.processed['min_' + namespace] = args.processed['zoom_' + namespace][0]; + args.processed['max_' + namespace] = args.processed['zoom_' + namespace][1]; + } + MG.call_hook('x_axis.process_min_max', args, args.processed.min_x, args.processed.max_x); + MG.call_hook('y_axis.process_min_max', args, args.processed.min_y, args.processed.max_y); +} + +function mg_categorical_group_color_scale(args) { + if (args.color_accessor !== false) { + if (args.ygroup_accessor) { + // add a custom accessor element. + if (args.color_accessor === null) { + args.color_accessor = args.y_accessor; + } else {} + } + if (args.color_accessor !== null) { + new MG.scale_factory(args) + .namespace('color') + .categoricalDomainFromData() + .categoricalColorRange(); + } + } +} + +function mg_add_color_categorical_scale(args, domain, accessor) { + args.scales.color = d3.scaleOrdinal(d3.schemeCategory20).domain(domain); + args.scalefns.color = function(d) { + return args.scales.color(d[accessor]); + }; +} + +function mg_get_categorical_domain(data, accessor) { + return d3.set(data.map(function(d) { + return d[accessor]; })) + .values(); +} + +function mg_get_color_domain(args) { + var color_domain; + if (args.color_domain === null) { + if (args.color_type === 'number') { + color_domain = d3.extent(args.data[0], function(d) { + return d[args.color_accessor]; }); + } else if (args.color_type === 'category') { + color_domain = mg_get_categorical_domain(args.data[0], args.color_accessor); + + } + } else { + color_domain = args.color_domain; + } + return color_domain; +} + +function mg_get_color_range(args) { + var color_range; + if (args.color_range === null) { + if (args.color_type === 'number') { + color_range = ['blue', 'red']; + } else { + color_range = null; + } + } else { + color_range = args.color_range; + } + return color_range; +} diff --git a/priv/static/js/metricsgraphics/common/window_listeners.js b/priv/static/js/metricsgraphics/common/window_listeners.js new file mode 100644 index 0000000..6dad3e9 --- /dev/null +++ b/priv/static/js/metricsgraphics/common/window_listeners.js @@ -0,0 +1,82 @@ +function MG_WindowResizeTracker() { + var targets = []; + + var Observer; + if (typeof MutationObserver !== "undefined") { + Observer = MutationObserver; + } else if (typeof WebKitMutationObserver !== "undefined") { + Observer = WebKitMutationObserver; + } + + function window_listener() { + targets.forEach(function(target) { + var svg = d3.select(target).select('svg'); + + // skip if svg is not visible + if (!svg.empty() && (svg.node().parentNode.offsetWidth > 0 || svg.node().parentNode.offsetHeight > 0)) { + var aspect = svg.attr('width') !== 0 ? (svg.attr('height') / svg.attr('width')) : 0; + + var newWidth = get_width(target); + + svg.attr('width', newWidth); + svg.attr('height', aspect * newWidth); + } + }); + } + + function remove_target(target) { + var index = targets.indexOf(target); + if (index !== -1) { + targets.splice(index, 1); + } + + if (targets.length === 0) { + window.removeEventListener('resize', window_listener, true); + } + } + + return { + add_target: function(target) { + if (targets.length === 0) { + window.addEventListener('resize', window_listener, true); + } + + if (targets.indexOf(target) === -1) { + targets.push(target); + + if (Observer) { + var observer = new Observer(function(mutations) { + var targetNode = d3.select(target).node(); + + if (!targetNode || mutations.some( + function(mutation) { + for (var i = 0; i < mutation.removedNodes.length; i++) { + if (mutation.removedNodes[i] === targetNode) { + return true; + } + } + })) { + observer.disconnect(); + remove_target(target); + } + }); + + observer.observe(d3.select(target).node().parentNode, { childList: true }); + } + } + } + }; +} + +var mg_window_resize_tracker = new MG_WindowResizeTracker(); + +function mg_window_listeners(args) { + mg_if_aspect_ratio_resize_svg(args); +} + +function mg_if_aspect_ratio_resize_svg(args) { + // have we asked the svg to fill a div, if so resize with div + if (args.full_width || args.full_height) { + mg_window_resize_tracker.add_target(args.target); + } +} diff --git a/priv/static/js/metricsgraphics/common/x_axis.js b/priv/static/js/metricsgraphics/common/x_axis.js new file mode 100644 index 0000000..c050f2b --- /dev/null +++ b/priv/static/js/metricsgraphics/common/x_axis.js @@ -0,0 +1,600 @@ +function x_rug(args) { + 'use strict'; + + if(!args.x_rug) { + return; + } + + args.rug_buffer_size = args.chart_type === 'point' + ? args.buffer / 2 + : args.buffer; + + var rug = mg_make_rug(args, 'mg-x-rug'); + + rug.attr('x1', args.scalefns.xf) + .attr('x2', args.scalefns.xf) + .attr('y1', args.height - args.bottom - args.rug_buffer_size) + .attr('y2', args.height - args.bottom); + + mg_add_color_accessor_to_rug(rug, args, 'mg-x-rug-mono'); +} + +MG.x_rug = x_rug; + +function mg_add_processed_object(args) { + if (!args.processed) { + args.processed = {}; + } +} + +// TODO ought to be deprecated, only used by histogram +function x_axis(args) { + 'use strict'; + + var svg = mg_get_svg_child_of(args.target); + mg_add_processed_object(args); + + mg_select_xax_format(args); + mg_selectAll_and_remove(svg, '.mg-x-axis'); + + if (!args.x_axis) { + return this; + } + + var g = mg_add_g(svg, 'mg-x-axis'); + + mg_add_x_ticks(g, args); + mg_add_x_tick_labels(g, args); + if (args.x_label) { mg_add_x_label(g, args); } + if (args.x_rug) { x_rug(args); } + + return this; +} + +MG.x_axis = x_axis; + +function x_axis_categorical(args) { + var svg = mg_get_svg_child_of(args.target); + var additional_buffer = 0; + if (args.chart_type === 'bar') { + additional_buffer = args.buffer + 5; + } + + mg_add_categorical_scale(args, 'X', args.categorical_variables.reverse(), args.left, mg_get_plot_right(args) - additional_buffer); + mg_add_scale_function(args, 'xf', 'X', 'value'); + mg_selectAll_and_remove(svg, '.mg-x-axis'); + + var g = mg_add_g(svg, 'mg-x-axis'); + + if (!args.x_axis) { + return this; + } + + mg_add_x_axis_categorical_labels(g, args, additional_buffer); + return this; +} + +function mg_add_x_axis_categorical_labels(g, args, additional_buffer) { + var labels = g.selectAll('text') + .data(args.categorical_variables) + .enter() + .append('text'); + + labels + .attr('x', function(d) { + return args.scales.X(d) + args.scales.X.bandwidth() / 2 + (args.buffer) * args.bar_outer_padding_percentage + (additional_buffer / 2); + }) + .attr('y', mg_get_plot_bottom(args)) + .attr('dy', '.35em') + .attr('text-anchor', 'middle') + .text(String); + + if (args.truncate_x_labels) { + labels.each(function(d, idx) { + var elem = this, width = args.scales.X.bandwidth(); + truncate_text(elem, d, width); + }); + } + mg_rotate_labels(labels, args.rotate_x_labels); +} + +MG.x_axis_categorical = x_axis_categorical; + +function mg_point_add_color_scale(args) { + var color_domain, color_range; + + if (args.color_accessor !== null) { + color_domain = mg_get_color_domain(args); + color_range = mg_get_color_range(args); + + if (args.color_type === 'number') { + args.scales.color = d3.scaleLinear() + .domain(color_domain) + .range(color_range) + .clamp(true); + } else { + args.scales.color = args.color_range !== null + ? d3.scaleOrdinal().range(color_range) + : (color_domain.length > 10 + ? d3.scaleOrdinal(d3.schemeCategory20) + : d3.scaleOrdinal(d3.schemeCategory10)); + + args.scales.color.domain(color_domain); + } + mg_add_scale_function(args, 'color', 'color', args.color_accessor); + } +} + +function mg_get_color_domain(args) { + var color_domain; + if (args.color_domain === null) { + if (args.color_type === 'number') { + color_domain = d3.extent(args.data[0], function(d) { + return d[args.color_accessor]; + }); + } else if (args.color_type === 'category') { + color_domain = d3.set(args.data[0] + .map(function(d) { + return d[args.color_accessor]; + })) + .values(); + + color_domain.sort(); + } + } else { + color_domain = args.color_domain; + } + return color_domain; +} + +function mg_get_color_range(args) { + var color_range; + if (args.color_range === null) { + if (args.color_type === 'number') { + color_range = ['blue', 'red']; + } else { + color_range = null; + } + } else { + color_range = args.color_range; + } + return color_range; +} + +function mg_point_add_size_scale(args) { + var min_size, max_size, size_domain, size_range; + if (args.size_accessor !== null) { + size_domain = mg_get_size_domain(args); + size_range = mg_get_size_range(args); + + args.scales.size = d3.scaleLinear() + .domain(size_domain) + .range(size_range) + .clamp(true); + + mg_add_scale_function(args, 'size', 'size', args.size_accessor); + } +} + +function mg_get_size_domain(args) { + return (args.size_domain === null) + ? d3.extent(args.data[0], function(d) { return d[args.size_accessor]; }) + : args.size_domain; +} + +function mg_get_size_range(args) { + var size_range; + if (args.size_range === null) { + size_range = [1, 5]; + } else { + size_range = args.size_range; + } + return size_range; +} + +function mg_add_x_label(g, args) { + if (args.x_label) { + g.append('text') + .attr('class', 'label') + .attr('x', function() { + return mg_get_plot_left(args) + (mg_get_plot_right(args) - mg_get_plot_left(args)) / 2; + }) + .attr('dx', args.x_label_nudge_x != null ? args.x_label_nudge_x : 0) + .attr('y', function() { + var xAxisTextElement = d3.select(args.target) + .select('.mg-x-axis text').node().getBoundingClientRect(); + return mg_get_bottom(args) + args.xax_tick_length * (7 / 3) + xAxisTextElement.height * 0.8 + 10; + }) + .attr('dy', '.5em') + .attr('text-anchor', 'middle') + .text(function(d) { + return args.x_label; + }); + } +} + +function mg_default_bar_xax_format(args) { + return function(d) { + if (d < 1.0 && d > -1.0 && d !== 0) { + // don't scale tiny values + return args.xax_units + d.toFixed(args.decimals); + } else { + var pf = d3.format(',.0f'); + return args.xax_units + pf(d); + } + }; +} + +function mg_get_time_frame(diff) { + // diff should be (max_x - min_x) / 1000, in other words, the difference in seconds. + var time_frame; + if (mg_milisec_diff(diff)) { + time_frame = 'millis'; + } else if (mg_sec_diff(diff)) { + time_frame = 'seconds'; + } else if (mg_day_diff(diff)) { + time_frame = 'less-than-a-day'; + } else if (mg_four_days(diff)) { + time_frame = 'four-days'; + } else if (mg_many_days(diff)) { // a handful of months? + time_frame = 'many-days'; + } else if (mg_many_months(diff)) { + time_frame = 'many-months'; + } else if (mg_years(diff)) { + time_frame = 'years'; + } else { + time_frame = 'default'; + } + return time_frame; +} + +function mg_milisec_diff(diff) { + return diff < 1; +} + +function mg_sec_diff(diff) { + return diff < 60; +} + +function mg_day_diff(diff) { + return diff / (60 * 60) < 24; +} + +function mg_four_days(diff) { + return diff / (60 * 60) < 24 * 4; +} + +function mg_many_days(diff) { + return diff / (60 * 60 * 24) < 60; +} + +function mg_many_months(diff) { + return diff / (60 * 60 * 24) < 365; +} + +function mg_years(diff) { + return diff / (60 * 60 * 24) >= 365; +} + +function mg_get_time_format(utc, diff) { + var main_time_format; + if (mg_milisec_diff(diff)) { + main_time_format = MG.time_format(utc, '%M:%S.%L'); + } else if (mg_sec_diff(diff)) { + main_time_format = MG.time_format(utc, '%M:%S'); + } else if (mg_day_diff(diff)) { + main_time_format = MG.time_format(utc, '%H:%M'); + } else if (mg_four_days(diff) || mg_many_days(diff)) { + main_time_format = MG.time_format(utc, '%b %d'); + } else if (mg_many_months(diff)) { + main_time_format = MG.time_format(utc, '%b'); + } else { + main_time_format = MG.time_format(utc, '%Y'); + } + return main_time_format; +} + +function mg_process_time_format(args) { + if (args.time_series) { + const diff = (args.processed.max_x - args.processed.min_x) / 1000; + const tickDiff = (args.processed.x_ticks[1] - args.processed.x_ticks[0]) / 1000; + args.processed.x_time_frame = mg_get_time_frame(diff); + args.processed.x_tick_diff_time_frame = mg_get_time_frame(tickDiff); + args.processed.main_x_time_format = mg_get_time_format(args.utc_time, tickDiff); + } +} + +function mg_default_xax_format(args) { + if (args.xax_format) { + return args.xax_format; + } + + var data = args.processed.original_data || args.data; + var flattened = mg_flatten_array(data)[0]; + var test_point_x = flattened[args.processed.original_x_accessor || args.x_accessor]; + if (test_point_x === undefined) { + test_point_x = flattened; + } + + return function(d) { + mg_process_time_format(args); + + if (mg_is_date(test_point_x)) { + return args.processed.main_x_time_format(new Date(d)); + } else if (typeof test_point_x === 'number') { + var is_float = d % 1 !== 0; + var pf; + + if (is_float) { + pf = d3.format(',.' + args.decimals + 'f'); + } else if (d < 1000) { + pf = d3.format(',.0f'); + } else { + pf = d3.format(',.2s'); + } + return args.xax_units + pf(d); + } else { + return args.xax_units + d; + } + }; +} + +function mg_add_x_ticks(g, args) { + mg_process_scale_ticks(args, 'x'); + mg_add_x_axis_rim(args, g); + mg_add_x_axis_tick_lines(args, g); +} + +function mg_add_x_axis_rim(args, g) { + var last_i = args.scales.X.ticks(args.xax_count).length - 1; + + if (!args.x_extended_ticks) { + g.append('line') + .attr('x1', function() { + if (args.xax_count === 0) { + return mg_get_plot_left(args); + } else if (args.axes_not_compact && args.chart_type !== 'bar') { + return args.left; + } else { + return (args.scales.X(args.scales.X.ticks(args.xax_count)[0])).toFixed(2); + } + }) + .attr('x2', function() { + if (args.xax_count === 0 || (args.axes_not_compact && args.chart_type !== 'bar')) { + return mg_get_right(args); + } else { + return args.scales.X(args.scales.X.ticks(args.xax_count)[last_i]).toFixed(2); + } + }) + .attr('y1', args.height - args.bottom) + .attr('y2', args.height - args.bottom); + } +} + +function mg_add_x_axis_tick_lines(args, g) { + g.selectAll('.mg-xax-ticks') + .data(args.processed.x_ticks).enter() + .append('line') + .attr('x1', function(d) { + return args.scales.X(d).toFixed(2); }) + .attr('x2', function(d) { + return args.scales.X(d).toFixed(2); }) + .attr('y1', args.height - args.bottom) + .attr('y2', function() { + return (args.x_extended_ticks) ? args.top : args.height - args.bottom + args.xax_tick_length; + }) + .attr('class', function() { + if (args.x_extended_ticks) { + return 'mg-extended-xax-ticks'; + } + }) + .classed('mg-xax-ticks', true); +} + +function mg_add_x_tick_labels(g, args) { + mg_add_primary_x_axis_label(args, g); + mg_add_secondary_x_axis_label(args, g); +} + +function mg_add_primary_x_axis_label(args, g) { + var labels = g.selectAll('.mg-xax-labels') + .data(args.processed.x_ticks).enter() + .append('text') + .attr('x', function(d) { + return args.scales.X(d).toFixed(2); + }) + .attr('y', (args.height - args.bottom + args.xax_tick_length * 7 / 3).toFixed(2)) + .attr('dy', '.50em') + .attr('text-anchor', 'middle'); + + if (args.time_series && args.european_clock) { + labels.append('tspan').classed('mg-european-hours', true).text(function(_d, i) { + var d = new Date(_d); + if (i === 0) return d3.timeFormat('%H')(d); + else return ''; + }); + labels.append('tspan').classed('mg-european-minutes-seconds', true).text(function(_d, i) { + var d = new Date(_d); + return ':' + args.processed.xax_format(d); + }); + } else { + labels.text(function(d) { + return args.xax_units + args.processed.xax_format(d); + }); + } + + // CHECK TO SEE IF OVERLAP for labels. If so, + // remove half of them. This is a dirty hack. + // We will need to figure out a more principled way of doing this. + if (mg_elements_are_overlapping(labels)) { + labels.filter(function(d, i) { + return (i + 1) % 2 === 0; + }).remove(); + + var svg = mg_get_svg_child_of(args.target); + svg.selectAll('.mg-xax-ticks') + .filter(function(d, i) { + return (i + 1) % 2 === 0; + }) + .remove(); + } +} + +function mg_add_secondary_x_axis_label(args, g) { + if (args.time_series && (args.show_years || args.show_secondary_x_label)) { + mg_add_secondary_x_axis_elements(args, g); + } +} + +function mg_get_yformat_and_secondary_time_function(args) { + let tf = { + timeframe: args.processed.x_time_frame, + tick_diff_timeframe: args.processed.x_tick_diff_time_frame + }; + switch (tf.timeframe) { + case 'millis': + case 'seconds': + tf.secondary = d3.timeDays; + if (args.european_clock) tf.yformat = MG.time_format(args.utc_time, '%b %d'); + else tf.yformat = MG.time_format(args.utc_time, '%I %p'); + break; + case 'less-than-a-day': + tf.secondary = d3.timeDays; + tf.yformat = MG.time_format(args.utc_time, '%b %d'); + break; + case 'four-days': + tf.secondary = d3.timeDays; + tf.yformat = MG.time_format(args.utc_time, '%b %d'); + break; + case 'many-days': + tf.secondary = d3.timeYears; + tf.yformat = MG.time_format(args.utc_time, '%Y'); + break; + case 'many-months': + tf.secondary = d3.timeYears; + tf.yformat = MG.time_format(args.utc_time, '%Y'); + break; + default: + tf.secondary = d3.timeYears; + tf.yformat = MG.time_format(args.utc_time, '%Y'); + } + return tf; +} + +function mg_add_secondary_x_axis_elements(args, g) { + var tf = mg_get_yformat_and_secondary_time_function(args); + + var years = tf.secondary(args.processed.min_x, args.processed.max_x); + if (years.length === 0) { + var first_tick = args.scales.X.ticks(args.xax_count)[0]; + years = [first_tick]; + } + + var yg = mg_add_g(g, 'mg-year-marker'); + if (tf.timeframe === 'default' && args.show_year_markers) { + mg_add_year_marker_line(args, yg, years, tf.yformat); + } + if (tf.tick_diff_time_frame != 'years') mg_add_year_marker_text(args, yg, years, tf.yformat); +} + +function mg_add_year_marker_line(args, g, years, yformat) { + g.selectAll('.mg-year-marker') + .data(years).enter() + .append('line') + .attr('x1', function(d) { + return args.scales.X(d).toFixed(2); + }) + .attr('x2', function(d) { + return args.scales.X(d).toFixed(2); + }) + .attr('y1', mg_get_top(args)) + .attr('y2', mg_get_bottom(args)); +} + +function mg_add_year_marker_text(args, g, years, yformat) { + g.selectAll('.mg-year-marker') + .data(years).enter() + .append('text') + .attr('x', function(d, i) { + return args.scales.X(d).toFixed(2); + }) + .attr('y', function() { + var xAxisTextElement = d3.select(args.target) + .select('.mg-x-axis text').node().getBoundingClientRect(); + return (mg_get_bottom(args) + args.xax_tick_length * 7 / 3) + (xAxisTextElement.height * 0.8); + }) + .attr('dy', '.50em') + .attr('text-anchor', 'middle') + .text(function(d) { + return yformat(new Date(d)); + }); +} + +function mg_min_max_x_for_nonbars(mx, args, data) { + var extent_x = d3.extent(data, function(d) { + return d[args.x_accessor]; + }); + mx.min = extent_x[0]; + mx.max = extent_x[1]; +} + +function mg_min_max_x_for_bars(mx, args, data) { + mx.min = d3.min(data, function(d) { + var trio = [ + d[args.x_accessor], + (d[args.baseline_accessor]) ? d[args.baseline_accessor] : 0, + (d[args.predictor_accessor]) ? d[args.predictor_accessor] : 0 + ]; + return Math.min.apply(null, trio); + }); + + if (mx.min > 0) mx.min = 0; + + mx.max = d3.max(data, function(d) { + var trio = [ + d[args.x_accessor], + (d[args.baseline_accessor]) ? d[args.baseline_accessor] : 0, + (d[args.predictor_accessor]) ? d[args.predictor_accessor] : 0 + ]; + return Math.max.apply(null, trio); + }); + return mx; +} + +function mg_min_max_x_for_dates(mx) { + var yesterday = MG.clone(mx.min).setDate(mx.min.getDate() - 1); + var tomorrow = MG.clone(mx.min).setDate(mx.min.getDate() + 1); + mx.min = yesterday; + mx.max = tomorrow; +} + +function mg_min_max_x_for_numbers(mx) { + // TODO do we want to rewrite this? + mx.min = mx.min - 1; + mx.max = mx.max + 1; +} + +function mg_min_max_x_for_strings(mx) { + // TODO shouldn't be allowing strings here to be coerced into numbers + mx.min = Number(mx.min) - 1; + mx.max = Number(mx.max) + 1; +} + +function mg_force_xax_count_to_be_two(args) { + args.xax_count = 2; +} + +function mg_select_xax_format(args) { + var c = args.chart_type; + if (!args.processed.xax_format) { + if (args.xax_format) { + args.processed.xax_format = args.xax_format; + } else { + if (c === 'line' || c === 'point' || c === 'histogram') { + args.processed.xax_format = mg_default_xax_format(args); + } else if (c === 'bar') { + args.processed.xax_format = mg_default_bar_xax_format(args); + } + } + } +} diff --git a/priv/static/js/metricsgraphics/common/y_axis.js b/priv/static/js/metricsgraphics/common/y_axis.js new file mode 100644 index 0000000..773d525 --- /dev/null +++ b/priv/static/js/metricsgraphics/common/y_axis.js @@ -0,0 +1,1072 @@ +function processScaleTicks (args, axis) { + var accessor = args[axis + '_accessor']; + var scale_ticks = args.scales[axis.toUpperCase()].ticks(args[axis + 'ax_count']); + var max = args.processed['max_' + axis]; + + function log10 (val) { + if (val === 1000) { + return 3; + } + if (val === 1000000) { + return 7; + } + return Math.log(val) / Math.LN10; + } + + if (args[axis + '_scale_type'] === 'log') { + // get out only whole logs + scale_ticks = scale_ticks.filter(function (d) { + return Math.abs(log10(d)) % 1 < 1e-6 || Math.abs(log10(d)) % 1 > 1 - 1e-6; + }); + } + + // filter out fraction ticks if our data is ints and if xmax > number of generated ticks + var number_of_ticks = scale_ticks.length; + + // is our data object all ints? + var data_is_int = true; + args.data.forEach(function (d, i) { + d.forEach(function (d, i) { + if (d[accessor] % 1 !== 0) { + data_is_int = false; + return false; + } + }); + }); + + if (data_is_int && number_of_ticks > max && args.format === 'count') { + // remove non-integer ticks + scale_ticks = scale_ticks.filter(function (d) { + return d % 1 === 0; + }); + } + + args.processed[axis + '_ticks'] = scale_ticks; +} + +function rugPlacement (args, axisArgs) { + var position = axisArgs.position; + var ns = axisArgs.namespace; + var coordinates = {}; + if (position === 'left') { + coordinates.x1 = mg_get_left(args) + 1; + coordinates.x2 = mg_get_left(args) + args.rug_buffer_size; + coordinates.y1 = args.scalefns[ns + 'f']; + coordinates.y2 = args.scalefns[ns + 'f']; + } + if (position === 'right') { + coordinates.x1 = mg_get_right(args) - 1; + coordinates.x2 = mg_get_right(args) - args.rug_buffer_size; + coordinates.y1 = args.scalefns[ns + 'f']; + coordinates.y2 = args.scalefns[ns + 'f']; + } + if (position === 'top') { + coordinates.x1 = args.scalefns[ns + 'f']; + coordinates.x2 = args.scalefns[ns + 'f']; + coordinates.y1 = mg_get_top(args) + 1; + coordinates.y2 = mg_get_top(args) + args.rug_buffer_size; + } + if (position === 'bottom') { + coordinates.x1 = args.scalefns[ns + 'f']; + coordinates.x2 = args.scalefns[ns + 'f']; + coordinates.y1 = mg_get_bottom(args) - 1; + coordinates.y2 = mg_get_bottom(args) - args.rug_buffer_size; + } + return coordinates; +} + +function rimPlacement (args, axisArgs) { + var ns = axisArgs.namespace; + var position = axisArgs.position; + var tick_length = args.processed[ns + '_ticks'].length; + var ticks = args.processed[ns + '_ticks']; + var scale = args.scales[ns.toUpperCase()]; + var coordinates = {}; + + if (position === 'left') { + coordinates.x1 = mg_get_left(args); + coordinates.x2 = mg_get_left(args); + coordinates.y1 = tick_length ? scale(ticks[0]).toFixed(2) : mg_get_top(args); + coordinates.y2 = tick_length ? scale(ticks[tick_length - 1]).toFixed(2) : mg_get_bottom(args); + } + if (position === 'right') { + coordinates.x1 = mg_get_right(args); + coordinates.x2 = mg_get_right(args); + coordinates.y1 = tick_length ? scale(ticks[0]).toFixed(2) : mg_get_top(args); + coordinates.y2 = tick_length ? scale(ticks[tick_length - 1]).toFixed(2) : mg_get_bottom(args); + } + if (position === 'top') { + coordinates.x1 = mg_get_left(args); + coordinates.x2 = mg_get_right(args); + coordinates.y1 = mg_get_top(args); + coordinates.y2 = mg_get_top(args); + } + if (position === 'bottom') { + coordinates.x1 = mg_get_left(args); + coordinates.x2 = mg_get_right(args); + coordinates.y1 = mg_get_bottom(args); + coordinates.y2 = mg_get_bottom(args); + } + + if (position === 'left' || position === 'right') { + if (args.axes_not_compact) { + coordinates.y1 = mg_get_bottom(args); + coordinates.y2 = mg_get_top(args); + } else if (tick_length) { + coordinates.y1 = scale(ticks[0]).toFixed(2); + coordinates.y2 = scale(ticks[tick_length - 1]).toFixed(2); + } + } + + return coordinates; +} + +function labelPlacement (args, axisArgs) { + var position = axisArgs.position; + var ns = axisArgs.namespace; + var tickLength = args[ns + 'ax_tick_length']; + var scale = args.scales[ns.toUpperCase()]; + var coordinates = {}; + + if (position === 'left') { + coordinates.x = mg_get_left(args) - tickLength * 3 / 2; + coordinates.y = function (d) { + return scale(d).toFixed(2); + }; + coordinates.dx = -3; + coordinates.dy = '.35em'; + coordinates.textAnchor = 'end'; + coordinates.text = function (d) { + return mg_compute_yax_format(args)(d); + }; + } + if (position === 'right') { + coordinates.x = mg_get_right(args) + tickLength * 3 / 2; + coordinates.y = function (d) { + return scale(d).toFixed(2); + }; + coordinates.dx = 3; + coordinates.dy = '.35em'; + coordinates.textAnchor = 'start'; + coordinates.text = function (d) { + return mg_compute_yax_format(args)(d); }; + } + if (position === 'top') { + coordinates.x = function (d) { + return scale(d).toFixed(2); + }; + coordinates.y = (mg_get_top(args) - tickLength * 7 / 3).toFixed(2); + coordinates.dx = 0; + coordinates.dy = '0em'; + coordinates.textAnchor = 'middle'; + coordinates.text = function (d) { + return mg_default_xax_format(args)(d); + }; + } + if (position === 'bottom') { + coordinates.x = function (d) { + return scale(d).toFixed(2); + }; + coordinates.y = (mg_get_bottom(args) + tickLength * 7 / 3).toFixed(2); + coordinates.dx = 0; + coordinates.dy = '.50em'; + coordinates.textAnchor = 'middle'; + coordinates.text = function (d) { + return mg_default_xax_format(args)(d); + }; + } + + return coordinates; +} + +function addSecondaryLabelElements (args, axisArgs, g) { + var tf = mg_get_yformat_and_secondary_time_function(args); + var years = tf.secondary(args.processed.min_x, args.processed.max_x); + if (years.length === 0) { + var first_tick = args.scales.X.ticks(args.xax_count)[0]; + years = [first_tick]; + } + + var yg = mg_add_g(g, 'mg-year-marker'); + if (tf.timeframe === 'default' && args.show_year_markers) { + yearMarkerLine(args, axisArgs, yg, years, tf.yformat); + } + if (tf.tick_diff_timeframe != 'years') yearMarkerText(args, axisArgs, yg, years, tf.yformat); +} + +function yearMarkerLine (args, axisArgs, g, years, yformat) { + g.selectAll('.mg-year-marker') + .data(years).enter() + .append('line') + .attr('x1', function (d) { + return args.scales.X(d).toFixed(2); }) + .attr('x2', function (d) { + return args.scales.X(d).toFixed(2); }) + .attr('y1', mg_get_top(args)) + .attr('y2', mg_get_bottom(args)); +} + +function yearMarkerText (args, axisArgs, g, years, yformat) { + var position = axisArgs.position; + var ns = axisArgs.namespace; + var scale = args.scales[ns.toUpperCase()]; + var x, y, dy, textAnchor, textFcn; + var xAxisTextElement = d3.select(args.target) + .select('.mg-x-axis text').node().getBoundingClientRect(); + + if (position === 'top') { + x = function (d, i) { + return scale(d).toFixed(2); }; + y = (mg_get_top(args) - args.xax_tick_length * 7 / 3) - (xAxisTextElement.height); + dy = '.50em'; + textAnchor = 'middle'; + textFcn = function (d) { + return yformat(new Date(d)); }; + } + if (position === 'bottom') { + x = function (d, i) { + return scale(d).toFixed(2); }; + y = (mg_get_bottom(args) + args.xax_tick_length * 7 / 3) + (xAxisTextElement.height * 0.8); + dy = '.50em'; + textAnchor = 'middle'; + textFcn = function (d) { + return yformat(new Date(d)); }; + } + + g.selectAll('.mg-year-marker') + .data(years).enter() + .append('text') + .attr('x', x) + .attr('y', y) + .attr('dy', dy) + .attr('text-anchor', textAnchor) + .text(textFcn); +} + +function addNumericalLabels (g, args, axisArgs) { + var ns = axisArgs.namespace; + var coords = labelPlacement(args, axisArgs); + var ticks = args.processed[ns + '_ticks']; + + var labels = g.selectAll('.mg-yax-labels') + .data(ticks).enter() + .append('text') + .attr('x', coords.x) + .attr('dx', coords.dx) + .attr('y', coords.y) + .attr('dy', coords.dy) + .attr('text-anchor', coords.textAnchor) + .text(coords.text); + // move the labels if they overlap + if (ns == 'x') { + if (args.time_series && args.european_clock) { + labels.append('tspan').classed('mg-european-hours', true).text(function (_d, i) { + var d = new Date(_d); + if (i === 0) return d3.timeFormat('%H')(d); + else return ''; + }); + labels.append('tspan').classed('mg-european-minutes-seconds', true).text(function (_d, i) { + var d = new Date(_d); + return ':' + args.processed.xax_format(d); + }); + } else { + labels.text(function (d) { + return args.xax_units + args.processed.xax_format(d); + }); + } + + if (args.time_series && (args.show_years || args.show_secondary_x_label)) { + addSecondaryLabelElements(args, axisArgs, g); + } + } + + if (mg_elements_are_overlapping(labels)) { + labels.filter(function (d, i) { + return (i + 1) % 2 === 0; + }).remove(); + + var svg = mg_get_svg_child_of(args.target); + svg.selectAll('.mg-' + ns + 'ax-ticks').filter(function (d, i) { + return (i + 1) % 2 === 0; }) + .remove(); + } +} + +function addTickLines (g, args, axisArgs) { + // name + var ns = axisArgs.namespace; + var position = axisArgs.position; + var scale = args.scales[ns.toUpperCase()]; + + var ticks = args.processed[ns + '_ticks']; + var ticksClass = 'mg-' + ns + 'ax-ticks'; + var extendedTicksClass = 'mg-extended-' + ns + 'ax-ticks'; + var extendedTicks = args[ns + '_extended_ticks']; + var tickLength = args[ns + 'ax_tick_length']; + + var x1, x2, y1, y2; + + if (position === 'left') { + x1 = mg_get_left(args); + x2 = extendedTicks ? mg_get_right(args) : mg_get_left(args) - tickLength; + y1 = function (d) { + return scale(d).toFixed(2); + }; + y2 = function (d) { + return scale(d).toFixed(2); + }; + } + if (position === 'right') { + x1 = mg_get_right(args); + x2 = extendedTicks ? mg_get_left(args) : mg_get_right(args) + tickLength; + y1 = function (d) { + return scale(d).toFixed(2); + }; + y2 = function (d) { + return scale(d).toFixed(2); + }; + } + if (position === 'top') { + x1 = function (d) { + return scale(d).toFixed(2); + }; + x2 = function (d) { + return scale(d).toFixed(2); + }; + y1 = mg_get_top(args); + y2 = extendedTicks ? mg_get_bottom(args) : mg_get_top(args) - tickLength; + } + if (position === 'bottom') { + x1 = function (d) { + return scale(d).toFixed(2); + }; + x2 = function (d) { + return scale(d).toFixed(2); + }; + y1 = mg_get_bottom(args); + y2 = extendedTicks ? mg_get_top(args) : mg_get_bottom(args) + tickLength; + } + + g.selectAll('.' + ticksClass) + .data(ticks).enter() + .append('line') + .classed(extendedTicksClass, extendedTicks) + .attr('x1', x1) + .attr('x2', x2) + .attr('y1', y1) + .attr('y2', y2); +} + +function initializeAxisRim (g, args, axisArgs) { + var namespace = axisArgs.namespace; + var tick_length = args.processed[namespace + '_ticks'].length; + + var rim = rimPlacement(args, axisArgs); + + if (!args[namespace + '_extended_ticks'] && !args[namespace + '_extended_ticks'] && tick_length) { + g.append('line') + .attr('x1', rim.x1) + .attr('x2', rim.x2) + .attr('y1', rim.y1) + .attr('y2', rim.y2); + } +} + +function initializeRug (args, rug_class) { + var svg = mg_get_svg_child_of(args.target); + var all_data = mg_flatten_array(args.data); + var rug = svg.selectAll('line.' + rug_class).data(all_data); + + // set the attributes that do not change after initialization, per + rug.enter().append('svg:line').attr('class', rug_class).attr('opacity', 0.3); + + // remove rug elements that are no longer in use + mg_exit_and_remove(rug); + + // set coordinates of new rug elements + mg_exit_and_remove(rug); + return rug; +} + +function rug (args, axisArgs) { + 'use strict'; + args.rug_buffer_size = args.chart_type === 'point' ? args.buffer / 2 : args.buffer * 2 / 3; + + var rug = initializeRug(args, 'mg-' + axisArgs.namespace + '-rug'); + var rug_positions = rugPlacement(args, axisArgs); + rug.attr('x1', rug_positions.x1) + .attr('x2', rug_positions.x2) + .attr('y1', rug_positions.y1) + .attr('y2', rug_positions.y2); + + mg_add_color_accessor_to_rug(rug, args, 'mg-' + axisArgs.namespace + '-rug-mono'); +} + +function categoricalLabelPlacement (args, axisArgs, group) { + var ns = axisArgs.namespace; + var position = axisArgs.position; + var scale = args.scales[ns.toUpperCase()]; + var groupScale = args.scales[(ns + 'group').toUpperCase()]; + var coords = {}; + coords.cat = {}; + coords.group = {}; + // x, y, dy, text-anchor + + if (position === 'left') { + coords.cat.x = mg_get_plot_left(args) - args.buffer; + coords.cat.y = function (d) { + return groupScale(group) + scale(d) + scale.bandwidth() / 2; + }; + coords.cat.dy = '.35em'; + coords.cat.textAnchor = 'end'; + coords.group.x = mg_get_plot_left(args) - args.buffer; + coords.group.y = groupScale(group) + (groupScale.bandwidth ? groupScale.bandwidth() / 2 : 0); + coords.group.dy = '.35em'; + coords.group.textAnchor = args['rotate_' + ns + '_labels'] ? 'end' : 'end'; + } + + if (position === 'right') { + coords.cat.x = mg_get_plot_right(args) - args.buffer; + coords.cat.y = function (d) { + return groupScale(group) + scale(d) + scale.bandwidth() / 2; + }; + coords.cat.dy = '.35em'; + coords.cat.textAnchor = 'start'; + coords.group.x = mg_get_plot_right(args) - args.buffer; + coords.group.y = groupScale(group) + (groupScale.bandwidth ? groupScale.bandwidth() / 2 : 0); + coords.group.dy = '.35em'; + coords.group.textAnchor = 'start'; + } + + if (position === 'top') { + coords.cat.x = function (d) { + return groupScale(group) + scale(d) + scale.bandwidth() / 2; + }; + coords.cat.y = mg_get_plot_top(args) + args.buffer; + coords.cat.dy = '.35em'; + coords.cat.textAnchor = args['rotate_' + ns + '_labels'] ? 'start' : 'middle'; + coords.group.x = groupScale(group) + (groupScale.bandwidth ? groupScale.bandwidth() / 2 : 0); + coords.group.y = mg_get_plot_top(args) + args.buffer; + coords.group.dy = '.35em'; + coords.group.textAnchor = args['rotate_' + ns + '_labels'] ? 'start' : 'middle'; + } + + if (position === 'bottom') { + coords.cat.x = function (d) { + return groupScale(group) + scale(d) + scale.bandwidth() / 2; + }; + coords.cat.y = mg_get_plot_bottom(args) + args.buffer; + coords.cat.dy = '.35em'; + coords.cat.textAnchor = args['rotate_' + ns + '_labels'] ? 'start' : 'middle'; + coords.group.x = groupScale(group) + (groupScale.bandwidth ? groupScale.bandwidth() / 2 - scale.bandwidth() / 2 : 0); + coords.group.y = mg_get_plot_bottom(args) + args.buffer; + coords.group.dy = '.35em'; + coords.group.textAnchor = args['rotate_' + ns + '_labels'] ? 'start' : 'middle'; + } + + return coords; +} + +function categoricalLabels (args, axisArgs) { + var ns = axisArgs.namespace; + var nsClass = 'mg-' + ns + '-axis'; + var scale = args.scales[ns.toUpperCase()]; + var groupScale = args.scales[(ns + 'group').toUpperCase()]; + var groupAccessor = ns + 'group_accessor'; + + var svg = mg_get_svg_child_of(args.target); + mg_selectAll_and_remove(svg, '.' + nsClass); + var g = mg_add_g(svg, nsClass); + var group_g; + var groups = groupScale.domain && groupScale.domain() + ? groupScale.domain() + : ['1']; + + groups.forEach(function (group) { + // grab group placement stuff. + var coords = categoricalLabelPlacement(args, axisArgs, group); + + var labels; + group_g = mg_add_g(g, 'mg-group-' + mg_normalize(group)); + if (args[groupAccessor] !== null) { + labels = group_g.append('text') + .classed('mg-barplot-group-label', true) + .attr('x', coords.group.x) + .attr('y', coords.group.y) + .attr('dy', coords.group.dy) + .attr('text-anchor', coords.group.textAnchor) + .text(group); + + } else { + labels = group_g.selectAll('text') + .data(scale.domain()) + .enter() + .append('text') + .attr('x', coords.cat.x) + .attr('y', coords.cat.y) + .attr('dy', coords.cat.dy) + .attr('text-anchor', coords.cat.textAnchor) + .text(String); + } + if (args['rotate_' + ns + '_labels']) { + rotateLabels(labels, args['rotate_' + ns + '_labels']); + } + }); +} + +function categoricalGuides (args, axisArgs) { + // for each group + // for each data point + + var ns = axisArgs.namespace; + var scalef = args.scalefns[ns + 'f']; + var groupf = args.scalefns[ns + 'groupf']; + var groupScale = args.scales[(ns + 'group').toUpperCase()]; + var scale = args.scales[ns.toUpperCase()]; + var position = axisArgs.position; + + var svg = mg_get_svg_child_of(args.target); + var alreadyPlotted = []; + + var x1, x2, y1, y2; + var grs = (groupScale.domain && groupScale.domain()) ? groupScale.domain() : [null]; + + mg_selectAll_and_remove(svg, '.mg-category-guides'); + var g = mg_add_g(svg, 'mg-category-guides'); + + grs.forEach(function (group) { + scale.domain().forEach(function (cat) { + if (position === 'left' || position === 'right') { + x1 = mg_get_plot_left(args); + x2 = mg_get_plot_right(args); + y1 = scale(cat) + groupScale(group) + scale.bandwidth() / 2; + y2 = scale(cat) + groupScale(group) + scale.bandwidth() / 2; + } + + if (position === 'top' || position === 'bottom') { + x1 = scale(cat) + groupScale(group) + scale.bandwidth() / 2 * (group === null); + x2 = scale(cat) + groupScale(group) + scale.bandwidth() / 2 * (group === null); + y1 = mg_get_plot_bottom(args); + y2 = mg_get_plot_top(args); + } + + g.append('line') + .attr('x1', x1) + .attr('x2', x2) + .attr('y1', y1) + .attr('y2', y2) + .attr('stroke-dasharray', '2,1'); + }); + + var first = groupScale(group) + scale(scale.domain()[0]) + scale.bandwidth() / 2 * (group === null || (position !== 'top' && position != 'bottom')); + var last = groupScale(group) + scale(scale.domain()[scale.domain().length - 1]) + scale.bandwidth() / 2 * (group === null || (position !== 'top' && position != 'bottom')); + + var x11, x21, y11, y21, x12, x22, y12, y22; + if (position === 'left' || position === 'right') { + x11 = mg_get_plot_left(args); + x21 = mg_get_plot_left(args); + y11 = first; + y21 = last; + + x12 = mg_get_plot_right(args); + x22 = mg_get_plot_right(args); + y12 = first; + y22 = last; + } + + if (position === 'bottom' || position === 'top') { + x11 = first; + x21 = last; + y11 = mg_get_plot_bottom(args); + y21 = mg_get_plot_bottom(args); + + x12 = first; + x22 = last; + y12 = mg_get_plot_top(args); + y22 = mg_get_plot_top(args); + } + + g.append('line') + .attr('x1', x11) + .attr('x2', x21) + .attr('y1', y11) + .attr('y2', y21) + .attr('stroke-dasharray', '2,1'); + + g.append('line') + .attr('x1', x12) + .attr('x2', x22) + .attr('y1', y12) + .attr('y2', y22) + .attr('stroke-dasharray', '2,1'); + }); +} + +function rotateLabels (labels, rotation_degree) { + if (rotation_degree) { + labels.attr('transform', function () { + var elem = d3.select(this); + return 'rotate(' + rotation_degree + ' ' + elem.attr('x') + ',' + elem.attr('y') + ')'; + }); + + } +} + +function zeroLine (args, axisArgs) { + var svg = mg_get_svg_child_of(args.target); + var ns = axisArgs.namespace; + var position = axisArgs.position; + var scale = args.scales[ns.toUpperCase()]; + var x1, x2, y1, y2; + if (position === 'left' || position === 'right') { + x1 = mg_get_plot_left(args); + x2 = mg_get_plot_right(args); + y1 = scale(0) + 1; + y2 = scale(0) + 1; + } + if (position === 'bottom' || position === 'top') { + y1 = mg_get_plot_top(args); + y2 = mg_get_plot_bottom(args); + x1 = scale(0) - 1; + x2 = scale(0) - 1; + } + + svg.append('line') + .attr('x1', x1) + .attr('x2', x2) + .attr('y1', y1) + .attr('y2', y2) + .attr('stroke', 'black'); +} + +var mgDrawAxis = {}; + +mgDrawAxis.categorical = function (args, axisArgs) { + var ns = axisArgs.namespace; + + categoricalLabels(args, axisArgs); + categoricalGuides(args, axisArgs); +}; + +mgDrawAxis.numerical = function (args, axisArgs) { + var namespace = axisArgs.namespace; + var axisName = namespace + '_axis'; + var axisClass = 'mg-' + namespace + '-axis'; + var svg = mg_get_svg_child_of(args.target); + + mg_selectAll_and_remove(svg, '.' + axisClass); + + if (!args[axisName]) { + return this; + } + + var g = mg_add_g(svg, axisClass); + + processScaleTicks(args, namespace); + initializeAxisRim(g, args, axisArgs); + addTickLines(g, args, axisArgs); + addNumericalLabels(g, args, axisArgs); + + // add label + if (args[namespace + '_label']) { + axisArgs.label(svg.select('.mg-' + namespace + '-axis'), args); + } + + // add rugs + if (args[namespace + '_rug']) { + rug(args, axisArgs); + } + + if (args.show_bar_zero) { + mg_bar_add_zero_line(args); + } + + return this; +}; + +function axisFactory (args) { + var axisArgs = {}; + axisArgs.type = 'numerical'; + + this.namespace = function (ns) { + // take the ns in the scale, and use it to + axisArgs.namespace = ns; + return this; + }; + + this.rug = function (tf) { + axisArgs.rug = tf; + return this; + }; + + this.label = function (tf) { + axisArgs.label = tf; + return this; + }; + + this.type = function (t) { + axisArgs.type = t; + return this; + }; + + this.position = function (pos) { + axisArgs.position = pos; + return this; + }; + + this.zeroLine = function (tf) { + axisArgs.zeroLine = tf; + return this; + }; + + this.draw = function () { + mgDrawAxis[axisArgs.type](args, axisArgs); + return this; + }; + + return this; + +} + +MG.axis_factory = axisFactory; + +/* ================================================================================ */ +/* ================================================================================ */ +/* ================================================================================ */ + +function y_rug (args) { + 'use strict'; + + if (!args.y_rug) { + return; + } + + args.rug_buffer_size = args.chart_type === 'point' + ? args.buffer / 2 + : args.buffer * 2 / 3; + + var rug = mg_make_rug(args, 'mg-y-rug'); + + rug.attr('x1', args.left + 1) + .attr('x2', args.left + args.rug_buffer_size) + .attr('y1', args.scalefns.yf) + .attr('y2', args.scalefns.yf); + + mg_add_color_accessor_to_rug(rug, args, 'mg-y-rug-mono'); +} + +MG.y_rug = y_rug; + +function mg_change_y_extents_for_bars (args, my) { + if (args.chart_type === 'bar') { + my.min = 0; + my.max = d3.max(args.data[0], function (d) { + var trio = []; + trio.push(d[args.y_accessor]); + + if (args.baseline_accessor !== null) { + trio.push(d[args.baseline_accessor]); + } + + if (args.predictor_accessor !== null) { + trio.push(d[args.predictor_accessor]); + } + + return Math.max.apply(null, trio); + }); + } + return my; +} + +function mg_compute_yax_format (args) { + var yax_format = args.yax_format; + if (!yax_format) { + let decimals = args.decimals; + if (args.format === 'count') { + // increase decimals if we have small values, useful for realtime data + if (args.processed.y_ticks.length > 1) { + // calculate the number of decimals between the difference of ticks + // based on approach in flot: https://github.com/flot/flot/blob/958e5fd43c6dff4bab3e1fd5cb6109df5c1e8003/jquery.flot.js#L1810 + decimals = Math.max(0, -Math.floor( + Math.log(Math.abs(args.processed.y_ticks[1] - args.processed.y_ticks[0])) / Math.LN10 + )); + } + + yax_format = function (d) { + var pf; + + if (decimals !== 0) { + // don't scale tiny values + pf = d3.format(',.' + decimals + 'f'); + } else if (d < 1000) { + pf = d3.format(',.0f'); + } else { + pf = d3.format(',.2s'); + } + + // are we adding units after the value or before? + if (args.yax_units_append) { + return pf(d) + args.yax_units; + } else { + return args.yax_units + pf(d); + } + }; + } else { // percentage + yax_format = function (d_) { + var n = d3.format('.0%'); + return n(d_); + }; + } + } + return yax_format; +} + +function mg_bar_add_zero_line (args) { + var svg = mg_get_svg_child_of(args.target); + var extents = args.scales.X.domain(); + if (0 >= extents[0] && extents[1] >= 0) { + var r = args.scales.Y.range(); + var g = args.categorical_groups.length + ? args.scales.YGROUP(args.categorical_groups[args.categorical_groups.length - 1]) + : args.scales.YGROUP(); + + svg.append('svg:line') + .attr('x1', args.scales.X(0)) + .attr('x2', args.scales.X(0)) + .attr('y1', r[0] + mg_get_plot_top(args)) + .attr('y2', r[r.length - 1] + g) + .attr('stroke', 'black') + .attr('opacity', 0.2); + } +} + +function mg_y_domain_range (args, scale) { + scale.domain([args.processed.min_y, args.processed.max_y]) + .range([mg_get_plot_bottom(args), args.top]); + return scale; +} + +function mg_define_y_scales (args) { + var scale = (mg_is_function(args.y_scale_type)) + ? args.y_scale_type() + : (args.y_scale_type === 'log') + ? d3.scaleLog() + : d3.scaleLinear(); + + if (args.y_scale_type === 'log') { + if (args.chart_type === 'histogram') { + // log histogram plots should start just below 1 + // so that bins with single counts are visible + args.processed.min_y = 0.2; + } else { + if (args.processed.min_y <= 0) { + args.processed.min_y = 1; + } + } + } + args.scales.Y = mg_y_domain_range(args, scale); + args.scales.Y.clamp(args.y_scale_type === 'log'); + + // used for ticks and such, and designed to be paired with log or linear + args.scales.Y_axis = mg_y_domain_range(args, d3.scaleLinear()); +} + +function mg_add_y_label (g, args) { + if (args.y_label) { + g.append('text') + .attr('class', 'label') + .attr('x', function () { + return -1 * (mg_get_plot_top(args) + + ((mg_get_plot_bottom(args)) - (mg_get_plot_top(args))) / 2); + }) + .attr('y', function () { + return args.left / 2; + }) + .attr('dy', '-1.2em') + .attr('text-anchor', 'middle') + .text(function (d) { + return args.y_label; + }) + .attr('transform', function (d) { + return 'rotate(-90)'; + }); + } +} + +function mg_add_y_axis_rim (g, args) { + var tick_length = args.processed.y_ticks.length; + if (!args.x_extended_ticks && !args.y_extended_ticks && tick_length) { + var y1scale, y2scale; + + if (args.axes_not_compact && args.chart_type !== 'bar') { + y1scale = args.height - args.bottom; + y2scale = args.top; + } else if (tick_length) { + y1scale = args.scales.Y(args.processed.y_ticks[0]).toFixed(2); + y2scale = args.scales.Y(args.processed.y_ticks[tick_length - 1]).toFixed(2); + } else { + y1scale = 0; + y2scale = 0; + } + + g.append('line') + .attr('x1', args.left) + .attr('x2', args.left) + .attr('y1', y1scale) + .attr('y2', y2scale); + } +} + +function mg_add_y_axis_tick_lines (g, args) { + g.selectAll('.mg-yax-ticks') + .data(args.processed.y_ticks).enter() + .append('line') + .classed('mg-extended-yax-ticks', args.y_extended_ticks) + .attr('x1', args.left) + .attr('x2', function () { + return (args.y_extended_ticks) ? args.width - args.right : args.left - args.yax_tick_length; + }) + .attr('y1', function (d) { + return args.scales.Y(d).toFixed(2); + }) + .attr('y2', function (d) { + return args.scales.Y(d).toFixed(2); + }); +} + +function mg_add_y_axis_tick_labels (g, args) { + var yax_format = mg_compute_yax_format(args); + g.selectAll('.mg-yax-labels') + .data(args.processed.y_ticks).enter() + .append('text') + .attr('x', args.left - args.yax_tick_length * 3 / 2) + .attr('dx', -3) + .attr('y', function (d) { + return args.scales.Y(d).toFixed(2); + }) + .attr('dy', '.35em') + .attr('text-anchor', 'end') + .text(function (d) { + var o = yax_format(d); + return o; + }); +} + +// TODO ought to be deprecated, only used by histogram +function y_axis (args) { + if (!args.processed) { + args.processed = {}; + } + + var svg = mg_get_svg_child_of(args.target); + MG.call_hook('y_axis.process_min_max', args, args.processed.min_y, args.processed.max_y); + mg_selectAll_and_remove(svg, '.mg-y-axis'); + + if (!args.y_axis) { + return this; + } + + var g = mg_add_g(svg, 'mg-y-axis'); + mg_add_y_label(g, args); + mg_process_scale_ticks(args, 'y'); + mg_add_y_axis_rim(g, args); + mg_add_y_axis_tick_lines(g, args); + mg_add_y_axis_tick_labels(g, args); + + if (args.y_rug) { + y_rug(args); + } + + return this; +} + +MG.y_axis = y_axis; + +function mg_add_categorical_labels (args) { + var svg = mg_get_svg_child_of(args.target); + mg_selectAll_and_remove(svg, '.mg-y-axis'); + var g = mg_add_g(svg, 'mg-y-axis'); + var group_g;(args.categorical_groups.length ? args.categorical_groups : ['1']).forEach(function (group) { + group_g = mg_add_g(g, 'mg-group-' + mg_normalize(group)); + + if (args.ygroup_accessor !== null) { + mg_add_group_label(group_g, group, args); + } else { + var labels = mg_add_graphic_labels(group_g, group, args); + mg_rotate_labels(labels, args.rotate_y_labels); + } + }); +} + +function mg_add_graphic_labels (g, group, args) { + return g.selectAll('text').data(args.scales.Y.domain()).enter().append('svg:text') + .attr('x', args.left - args.buffer) + .attr('y', function (d) { + return args.scales.YGROUP(group) + args.scales.Y(d) + args.scales.Y.bandwidth() / 2; + }) + .attr('dy', '.35em') + .attr('text-anchor', 'end') + .text(String); +} + +function mg_add_group_label (g, group, args) { + g.append('svg:text') + .classed('mg-barplot-group-label', true) + .attr('x', args.left - args.buffer) + .attr('y', args.scales.YGROUP(group) + args.scales.YGROUP.bandwidth() / 2) + .attr('dy', '.35em') + .attr('text-anchor', 'end') + .text(group); +} + +function mg_draw_group_lines (args) { + var svg = mg_get_svg_child_of(args.target); + var groups = args.scales.YGROUP.domain(); + var first = groups[0]; + var last = groups[groups.length - 1]; + + svg.select('.mg-category-guides').selectAll('mg-group-lines') + .data(groups) + .enter().append('line') + .attr('x1', mg_get_plot_left(args)) + .attr('x2', mg_get_plot_left(args)) + .attr('y1', function (d) { + return args.scales.YGROUP(d); + }) + .attr('y2', function (d) { + return args.scales.YGROUP(d) + args.ygroup_height; + }) + .attr('stroke-width', 1); +} + +function mg_y_categorical_show_guides (args) { + // for each group + // for each data point + var svg = mg_get_svg_child_of(args.target); + var alreadyPlotted = []; + args.data[0].forEach(function (d) { + if (alreadyPlotted.indexOf(d[args.y_accessor]) === -1) { + svg.select('.mg-category-guides').append('line') + .attr('x1', mg_get_plot_left(args)) + .attr('x2', mg_get_plot_right(args)) + .attr('y1', args.scalefns.yf(d) + args.scalefns.ygroupf(d)) + .attr('y2', args.scalefns.yf(d) + args.scalefns.ygroupf(d)) + .attr('stroke-dasharray', '2,1'); + } + }); +} + +function y_axis_categorical (args) { + if (!args.y_axis) { + return this; + } + + mg_add_categorical_labels(args); + // mg_draw_group_scaffold(args); + if (args.show_bar_zero) mg_bar_add_zero_line(args); + if (args.ygroup_accessor) mg_draw_group_lines(args); + if (args.y_categorical_show_guides) mg_y_categorical_show_guides(args); + return this; +} + +MG.y_axis_categorical = y_axis_categorical; diff --git a/priv/static/js/metricsgraphics/common/zoom.js b/priv/static/js/metricsgraphics/common/zoom.js new file mode 100644 index 0000000..eb877f2 --- /dev/null +++ b/priv/static/js/metricsgraphics/common/zoom.js @@ -0,0 +1,85 @@ +{ + +const filter_in_range_data = (args, range) => { + const is_data_in_range = (data, range) => { + return data > Math.min(range[0], range[1]) && data < Math.max(range[0], range[1]); + }; + // if range without this axis return true, else judge is data in range or not. + return d => ['x', 'y'].every(dim => !(dim in range) || is_data_in_range(d[args[`${dim}_accessor`]], range[dim])); +}; + +// the range here is the range of data +// range is an object with two optional attributes of x,y, respectively represent ranges on two axes +const zoom_to_data_domain = (args, range) => { + const raw_data = args.processed.raw_data || args.data; + // store raw data and raw domain to in order to zoom back to the initial state + if (!('raw_data' in args.processed)) { + args.processed.raw_domain = { + x: args.scales.X.domain(), + y: args.scales.Y.domain() + }; + args.processed.raw_data = raw_data; + } + if (['x', 'y'].some(dim => range[dim][0] === range[dim][1])) return; + // to avoid drawing outside the chart in the point chart, unnecessary in line chart. + if (args.chart_type === 'point') { + if (is_array_of_arrays(raw_data)) { + args.data = raw_data.map(function(d) { + return d.filter(filter_in_range_data(args, range)); + }); + } else { + args.data = raw_data.filter(filter_in_range_data(args, range)); + } + if (mg_flatten_array(args.data).length === 0) return; + } + ['x', 'y'].forEach(dim => { + if (dim in range) args.processed[`zoom_${dim}`] = range[dim]; + else delete args.processed[`zoom_${dim}`]; + }); + if (args.processed.subplot) { + if (range !== args.processed.raw_domain) { + MG.create_brushing_pattern(args.processed.subplot, convert_domain_to_range(args.processed.subplot, range)); + } else { + MG.remove_brushing_pattern(args.processed.subplot); + } + } + new MG.charts[args.chart_type || defaults.chart_type].descriptor(args); +}; + +const zoom_to_raw_range = args => { + if (!('raw_domain' in args.processed)) return; + zoom_to_data_domain(args, args.processed.raw_domain); + delete args.processed.raw_domain; + delete args.processed.raw_data; +}; + +// converts the range of selection into the range of data that we can use to +// zoom the chart to a particular region +const convert_range_to_domain = (args, range) => + ['x', 'y'].reduce((domain, dim) => { + if (!(dim in range)) return domain; + domain[dim] = range[dim].map(v => +args.scales[dim.toUpperCase()].invert(v)); + if (dim === 'y') domain[dim].reverse(); + return domain; + }, {}); + +const convert_domain_to_range = (args, domain) => + ['x', 'y'].reduce((range, dim) => { + if (!(dim in domain)) return range; + range[dim] = domain[dim].map(v => +args.scales[dim.toUpperCase()](v)); + if (dim === 'y') range[dim].reverse(); + return range; + }, {}); + +// the range here is the range of selection +const zoom_to_data_range = (args, range) => { + const domain = convert_range_to_domain(args, range); + zoom_to_data_domain(args, domain); +}; + +MG.convert_range_to_domain = convert_range_to_domain; +MG.zoom_to_data_domain = zoom_to_data_domain; +MG.zoom_to_data_range = zoom_to_data_range; +MG.zoom_to_raw_range = zoom_to_raw_range; + +} diff --git a/priv/static/js/metricsgraphics/layout/bootstrap_dropdown.js b/priv/static/js/metricsgraphics/layout/bootstrap_dropdown.js new file mode 100755 index 0000000..41dbfc5 --- /dev/null +++ b/priv/static/js/metricsgraphics/layout/bootstrap_dropdown.js @@ -0,0 +1,177 @@ +if (mg_jquery_exists()) { + /*! + * Bootstrap v3.3.1 (http://getbootstrap.com) + * Copyright 2011-2014 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + */ + + /*! + * Generated using the Bootstrap Customizer (http://getbootstrap.com/customize/?id=c3834cc5b59ef727da53) + * Config saved to config.json and https://gist.github.com/c3834cc5b59ef727da53 + */ + + /* ======================================================================== + * Bootstrap: dropdown.js v3.3.1 + * http://getbootstrap.com/javascript/#dropdowns + * ======================================================================== + * Copyright 2011-2014 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * ======================================================================== */ + + + +function ($) { + 'use strict'; + + if(typeof $().dropdown == 'function') + return true; + + // DROPDOWN CLASS DEFINITION + // ========================= + + var backdrop = '.dropdown-backdrop'; + var toggle = '[data-toggle="dropdown"]'; + var Dropdown = function (element) { + $(element).on('click.bs.dropdown', this.toggle); + }; + + Dropdown.VERSION = '3.3.1'; + + Dropdown.prototype.toggle = function (e) { + var $this = $(this); + + if ($this.is('.disabled, :disabled')) return; + + var $parent = getParent($this); + var isActive = $parent.hasClass('open'); + + clearMenus(); + + if (!isActive) { + if ('ontouchstart' in document.documentElement && !$parent.closest('.navbar-nav').length) { + // if mobile we use a backdrop because click events don't delegate + $('<div class="dropdown-backdrop"/>').insertAfter($(this)).on('click', clearMenus); + } + + var relatedTarget = { relatedTarget: this }; + $parent.trigger(e = $.Event('show.bs.dropdown', relatedTarget)); + + if (e.isDefaultPrevented()) return; + + $this + .trigger('focus') + .attr('aria-expanded', 'true'); + + $parent + .toggleClass('open') + .trigger('shown.bs.dropdown', relatedTarget); + } + + return false; + }; + + Dropdown.prototype.keydown = function (e) { + if (!/(38|40|27|32)/.test(e.which) || /input|textarea/i.test(e.target.tagName)) return; + + var $this = $(this); + + e.preventDefault(); + e.stopPropagation(); + + if ($this.is('.disabled, :disabled')) return; + + var $parent = getParent($this); + var isActive = $parent.hasClass('open'); + + if ((!isActive && e.which != 27) || (isActive && e.which == 27)) { + if (e.which == 27) $parent.find(toggle).trigger('focus'); + return $this.trigger('click'); + } + + var desc = ' li:not(.divider):visible a'; + var $items = $parent.find('[role="menu"]' + desc + ', [role="listbox"]' + desc); + + if (!$items.length) return; + + var index = $items.index(e.target); + + if (e.which == 38 && index > 0) index--; // up + if (e.which == 40 && index < $items.length - 1) index++; // down + if (!~index) index = 0; + + $items.eq(index).trigger('focus'); + }; + + function clearMenus(e) { + if (e && e.which === 3) return; + $(backdrop).remove(); + $(toggle).each(function () { + var $this = $(this); + var $parent = getParent($this); + var relatedTarget = { relatedTarget: this }; + + if (!$parent.hasClass('open')) return; + + $parent.trigger(e = $.Event('hide.bs.dropdown', relatedTarget)); + + if (e.isDefaultPrevented()) return; + + $this.attr('aria-expanded', 'false'); + $parent.removeClass('open').trigger('hidden.bs.dropdown', relatedTarget); + }); + } + + function getParent($this) { + var selector = $this.attr('data-target'); + + if (!selector) { + selector = $this.attr('href'); + selector = selector && /#[A-Za-z]/.test(selector) && selector.replace(/.*(?=#[^\s]*$)/, ''); // strip for ie7 + } + + var $parent = selector && $(selector); + + return $parent && $parent.length ? $parent : $this.parent(); + } + + + // DROPDOWN PLUGIN DEFINITION + // ========================== + + function Plugin(option) { + return this.each(function () { + var $this = $(this); + var data = $this.data('bs.dropdown'); + + if (!data) $this.data('bs.dropdown', (data = new Dropdown(this))); + if (typeof option == 'string') data[option].call($this); + }); + } + + var old = $.fn.dropdown; + + $.fn.dropdown = Plugin; + $.fn.dropdown.Constructor = Dropdown; + + + // DROPDOWN NO CONFLICT + // ==================== + + $.fn.dropdown.noConflict = function () { + $.fn.dropdown = old; + return this; + }; + + + // APPLY TO STANDARD DROPDOWN ELEMENTS + // =================================== + + $(document) + .on('click.bs.dropdown.data-api', clearMenus) + .on('click.bs.dropdown.data-api', '.dropdown form', function (e) { e.stopPropagation(); }) + .on('click.bs.dropdown.data-api', toggle, Dropdown.prototype.toggle) + .on('keydown.bs.dropdown.data-api', toggle, Dropdown.prototype.keydown) + .on('keydown.bs.dropdown.data-api', '[role="menu"]', Dropdown.prototype.keydown) + .on('keydown.bs.dropdown.data-api', '[role="listbox"]', Dropdown.prototype.keydown); + + }(jQuery); +} diff --git a/priv/static/js/metricsgraphics/layout/button.js b/priv/static/js/metricsgraphics/layout/button.js new file mode 100644 index 0000000..b122273 --- /dev/null +++ b/priv/static/js/metricsgraphics/layout/button.js @@ -0,0 +1,126 @@ +MG.button_layout = function(target) { + 'use strict'; + this.target = target; + this.feature_set = {}; + this.public_name = {}; + this.sorters = {}; + this.manual = []; + this.manual_map = {}; + this.manual_callback = {}; + + this._strip_punctuation = function(s) { + var punctuationless = s.replace(/[^a-zA-Z0-9 _]+/g, ''); + var finalString = punctuationless.replace(/ +?/g, ''); + return finalString; + }; + + this.data = function(data) { + this._data = data; + return this; + }; + + this.manual_button = function(feature, feature_set, callback) { + this.feature_set[feature] = feature_set; + this.manual_map[this._strip_punctuation(feature)] = feature; + this.manual_callback[feature] = callback; // the default is going to be the first feature. + return this; + }; + + this.button = function(feature) { + if (arguments.length > 1) { + this.public_name[feature] = arguments[1]; + } + + if (arguments.length > 2) { + this.sorters[feature] = arguments[2]; + } + + this.feature_set[feature] = []; + return this; + }; + + this.callback = function(callback) { + this._callback = callback; + return this; + }; + + this.display = function() { + var callback = this._callback; + var manual_callback = this.manual_callback; + var manual_map = this.manual_map; + + var d, f, features, feat; + features = Object.keys(this.feature_set); + + var mapDtoF = function(f) { + return d[f]; }; + + var i; + + // build out this.feature_set with this.data + for (i = 0; i < this._data.length; i++) { + d = this._data[i]; + f = features.map(mapDtoF); + for (var j = 0; j < features.length; j++) { + feat = features[j]; + if (this.feature_set[feat].indexOf(f[j]) === -1) { + this.feature_set[feat].push(f[j]); + } + } + } + + for (feat in this.feature_set) { + if (this.sorters.hasOwnProperty(feat)) { + this.feature_set[feat].sort(this.sorters[feat]); + } + } + + $(this.target).empty(); + + $(this.target).append("<div class='col-lg-12 segments text-center'></div>"); + + var dropdownLiAClick = function() { + var k = $(this).data('key'); + var feature = $(this).data('feature'); + var manual_feature; + $('.' + feature + '-btns button.btn span.title').html(k); + if (!manual_map.hasOwnProperty(feature)) { + callback(feature, k); + } else { + manual_feature = manual_map[feature]; + manual_callback[manual_feature](k); + } + + return false; + }; + + for (var feature in this.feature_set) { + features = this.feature_set[feature]; + $(this.target + ' div.segments').append( + '<div class="btn-group ' + this._strip_punctuation(feature) + '-btns text-left">' + // This never changes. + '<button type="button" class="btn btn-default btn-lg dropdown-toggle" data-toggle="dropdown">' + + "<span class='which-button'>" + (this.public_name.hasOwnProperty(feature) ? this.public_name[feature] : feature) + "</span>" + + "<span class='title'>" + (this.manual_callback.hasOwnProperty(feature) ? this.feature_set[feature][0] : 'all') + "</span>" + // if a manual button, don't default to all in label. + '<span class="caret"></span>' + + '</button>' + + '<ul class="dropdown-menu" role="menu">' + + (!this.manual_callback.hasOwnProperty(feature) ? '<li><a href="#" data-feature="' + feature + '" data-key="all">All</a></li>' : "") + + (!this.manual_callback.hasOwnProperty(feature) ? '<li class="divider"></li>' : "") + + '</ul>' + '</div>'); + + for (i = 0; i < features.length; i++) { + if (features[i] !== 'all' && features[i] !== undefined) { // strange bug with undefined being added to manual buttons. + $(this.target + ' div.' + this._strip_punctuation(feature) + '-btns ul.dropdown-menu').append( + '<li><a href="#" data-feature="' + this._strip_punctuation(feature) + '" data-key="' + features[i] + '">' + features[i] + '</a></li>' + ); + } + } + + $('.' + this._strip_punctuation(feature) + '-btns .dropdown-menu li a').on('click', dropdownLiAClick); + } + + return this; + }; + + return this; +}; diff --git a/priv/static/js/metricsgraphics/misc/error.js b/priv/static/js/metricsgraphics/misc/error.js new file mode 100644 index 0000000..cf2eee3 --- /dev/null +++ b/priv/static/js/metricsgraphics/misc/error.js @@ -0,0 +1,16 @@ +// call this to add a warning icon to a graph and log an error to the console +function error(args) { + console.error('ERROR : ', args.target, ' : ', args.error); + + d3.select(args.target).select('.mg-chart-title') + .append('tspan') + .attr('class', 'fa fa-x fa-exclamation-circle mg-warning') + .attr('dx', '0.3em') + .text('\uf06a'); +} + +function internal_error(args) { + console.error('INTERNAL ERROR : ', args.target, ' : ', args.internal_error); +} + +MG.error = error; diff --git a/priv/static/js/metricsgraphics/misc/formatters.js b/priv/static/js/metricsgraphics/misc/formatters.js new file mode 100644 index 0000000..1533fd5 --- /dev/null +++ b/priv/static/js/metricsgraphics/misc/formatters.js @@ -0,0 +1,147 @@ +function format_rollover_number(args) { + var num; + if (args.format === 'count') { + num = function(d) { + var is_float = d % 1 !== 0; + var pf; + + if (is_float) { + pf = d3.format(',.' + args.decimals + 'f'); + } else { + pf = d3.format(',.0f'); + } + + // are we adding units after the value or before? + if (args.yax_units_append) { + return pf(d) + args.yax_units; + } else { + return args.yax_units + pf(d); + } + }; + } else { + num = function(d_) { + var fmt_string = (isNumeric(args.decimals) ? '.' + args.decimals : '') + '%'; + var pf = d3.format(fmt_string); + return pf(d_); + }; + } + return num; +} + +var time_rollover_format = function(f, d, accessor, utc) { + var fd; + if (typeof f === 'string') { + fd = MG.time_format(utc, f)(d[accessor]); + } else if (typeof f === 'function') { + fd = f(d); + } else { + fd = d[accessor]; + } + return fd; +}; + +// define our rollover format for numbers +var number_rollover_format = function(f, d, accessor) { + var fd; + if (typeof f === 'string') { + fd = d3.format('s')(d[accessor]); + } else if (typeof f === 'function') { + fd = f(d); + } else { + fd = d[accessor]; + } + return fd; +}; + +function mg_format_y_rollover(args, num, d) { + var formatted_y; + if (args.y_mouseover !== null) { + if (args.aggregate_rollover) { + formatted_y = number_rollover_format(args.y_mouseover, d, args.y_accessor); + } else { + formatted_y = number_rollover_format(args.y_mouseover, d, args.y_accessor); + } + } else { + if (args.time_series) { + if (args.aggregate_rollover) { + formatted_y = num(d[args.y_accessor]); + } else { + formatted_y = args.yax_units + num(d[args.y_accessor]); + } + } else { + formatted_y = args.y_accessor + ': ' + args.yax_units + num(d[args.y_accessor]); + } + } + return formatted_y; +} + +function mg_format_x_rollover(args, fmt, d) { + var formatted_x; + if (args.x_mouseover !== null) { + if (args.time_series) { + if (args.aggregate_rollover) { + formatted_x = time_rollover_format(args.x_mouseover, d, 'key', args.utc); + } else { + formatted_x = time_rollover_format(args.x_mouseover, d, args.x_accessor, args.utc); + } + } else { + formatted_x = number_rollover_format(args.x_mouseover, d, args.x_accessor); + } + } else { + if (args.time_series) { + var date; + + if (args.aggregate_rollover && args.data.length > 1) { + date = new Date(d.key); + } else { + date = new Date(+d[args.x_accessor]); + date.setDate(date.getDate()); + } + + formatted_x = fmt(date) + ' '; + } else { + formatted_x = args.x_accessor + ': ' + d[args.x_accessor] + ' '; + } + } + return formatted_x; +} + +function mg_format_data_for_mouseover(args, d, mouseover_fcn, accessor, check_time) { + var formatted_data, formatter; + var time_fmt = mg_get_rollover_time_format(args); + if (typeof d[accessor] === 'string') { + formatter = function(d) { + return d; + }; + } else { + formatter = format_rollover_number(args); + } + + if (mouseover_fcn !== null) { + if (check_time) formatted_data = time_rollover_format(mouseover_fcn, d, accessor, args.utc); + else formatted_data = number_rollover_format(mouseover_fcn, d, accessor); + + } else { + if (check_time) formatted_data = time_fmt(new Date(+d[accessor])) + ' '; + else formatted_data = (args.time_series ? '' : accessor + ': ') + formatter(d[accessor]) + ' '; + } + return formatted_data; +} + +function mg_format_number_mouseover(args, d) { + return mg_format_data_for_mouseover(args, d, args.x_mouseover, args.x_accessor, false); +} + +function mg_format_x_mouseover(args, d) { + return mg_format_data_for_mouseover(args, d, args.x_mouseover, args.x_accessor, args.time_series); +} + +function mg_format_y_mouseover(args, d) { + return mg_format_data_for_mouseover(args, d, args.y_mouseover, args.y_accessor, false); +} + +function mg_format_x_aggregate_mouseover(args, d) { + return mg_format_data_for_mouseover(args, d, args.x_mouseover, 'key', args.time_series); +} + +MG.format_rollover_number = format_rollover_number; diff --git a/priv/static/js/metricsgraphics/misc/markup.js b/priv/static/js/metricsgraphics/misc/markup.js new file mode 100644 index 0000000..c49f12a --- /dev/null +++ b/priv/static/js/metricsgraphics/misc/markup.js @@ -0,0 +1,66 @@ +// influenced by https://bl.ocks.org/tomgp/c99a699587b5c5465228 + +function render_markup_for_server(callback) { + var virtual_window = MG.virtual_window; + var virtual_d3 = d3.select(virtual_window.document); + var target = virtual_window.document.createElement('div'); + + var original_d3 = global.d3; + var original_window = global.window; + var original_document = global.document; + global.d3 = virtual_d3; + global.window = virtual_window; + global.document = virtual_window.document; + + var error; + try { + callback(target); + } catch(e) { + error = e; + } + + global.d3 = original_d3; + global.window = original_window; + global.document = original_document; + + if (error) { + throw error; + } + + /* for some reason d3.select parses jsdom elements incorrectly + * but it works if we wrap the element in a function. + */ + return virtual_d3.select(function targetFn() { + return target; + }).html(); +} + +function render_markup_for_client(callback) { + var target = document.createElement('div'); + callback(target); + return d3.select(target).html(); +} + +function render_markup(callback) { + switch(typeof window) { + case 'undefined': + return render_markup_for_server(callback); + default: + return render_markup_for_client(callback); + } +} + +function init_virtual_window(jsdom, force) { + if (MG.virtual_window && !force) { + return; + } + + var doc = jsdom.jsdom({ + html: '', + features: { QuerySelector: true } + }); + MG.virtual_window = doc.defaultView; +} + +MG.render_markup = render_markup; +MG.init_virtual_window = init_virtual_window; diff --git a/priv/static/js/metricsgraphics/misc/process.js b/priv/static/js/metricsgraphics/misc/process.js new file mode 100644 index 0000000..d0d312d --- /dev/null +++ b/priv/static/js/metricsgraphics/misc/process.js @@ -0,0 +1,368 @@ +function mg_process_scale_ticks(args, axis) { + var accessor; + var scale_ticks; + var max; + + if (axis === 'x') { + accessor = args.x_accessor; + scale_ticks = args.scales.X.ticks(args.xax_count); + max = args.processed.max_x; + } else if (axis === 'y') { + accessor = args.y_accessor; + scale_ticks = args.scales.Y.ticks(args.yax_count); + max = args.processed.max_y; + } + + function log10(val) { + if (val === 1000) { + return 3; + } + if (val === 1000000) { + return 7; + } + return Math.log(val) / Math.LN10; + } + + if ((axis === 'x' && args.x_scale_type === 'log') || (axis === 'y' && args.y_scale_type === 'log')) { + // get out only whole logs + scale_ticks = scale_ticks.filter(function(d) { + return Math.abs(log10(d)) % 1 < 1e-6 || Math.abs(log10(d)) % 1 > 1 - 1e-6; + }); + } + + // filter out fraction ticks if our data is ints and if xmax > number of generated ticks + var number_of_ticks = scale_ticks.length; + + // is our data object all ints? + var data_is_int = true; + args.data.forEach(function(d, i) { + d.forEach(function(d, i) { + if (d[accessor] % 1 !== 0) { + data_is_int = false; + return false; + } + }); + }); + + if (data_is_int && number_of_ticks > max && args.format === 'count') { + // remove non-integer ticks + scale_ticks = scale_ticks.filter(function(d) { + return d % 1 === 0; + }); + } + + if (axis === 'x') { + args.processed.x_ticks = scale_ticks; + } else if (axis === 'y') { + args.processed.y_ticks = scale_ticks; + } +} + +function raw_data_transformation(args) { + 'use strict'; + + // dupe our data so we can modify it without adverse effect + args.data = MG.clone(args.data); + + // we need to account for a few data format cases: + // #0 {bar1:___, bar2:___} // single object (for, say, bar charts) + // #1 [{key:__, value:__}, ...] // unnested obj-arrays + // #2 [[{key:__, value:__}, ...], [{key:__, value:__}, ...]] // nested obj-arrays + // #3 [[4323, 2343],..] // unnested 2d array + // #4 [[[4323, 2343],..] , [[4323, 2343],..]] // nested 2d array + args.single_object = false; // for bar charts. + args.array_of_objects = false; + args.array_of_arrays = false; + args.nested_array_of_arrays = false; + args.nested_array_of_objects = false; + + // is the data object a nested array? + + if (is_array_of_arrays(args.data)) { + args.nested_array_of_objects = args.data.map(function(d) { + return is_array_of_objects_or_empty(d); + }); // Case #2 + args.nested_array_of_arrays = args.data.map(function(d) { + return is_array_of_arrays(d); + }); // Case #4 + } else { + args.array_of_objects = is_array_of_objects(args.data); // Case #1 + args.array_of_arrays = is_array_of_arrays(args.data); // Case #3 + } + + if (args.chart_type === 'line') { + if (args.array_of_objects || args.array_of_arrays) { + args.data = [args.data]; + } + } else { + if (!(mg_is_array(args.data[0]))) { + args.data = [args.data]; + } + } + // if the y_accessor is an array, break it up and store the result in args.data + mg_process_multiple_x_accessors(args); + mg_process_multiple_y_accessors(args); + + // if user supplies keyword in args.color, change to arg.colors. + // this is so that the API remains fairly sensible and legible. + if (args.color !== undefined) { + args.colors = args.color; + } + + // if user has supplied args.colors, and that value is a string, turn it into an array. + if (args.colors !== null && typeof args.colors === 'string') { + args.colors = [args.colors]; + } + + // sort x-axis data + if (args.chart_type === 'line' && args.x_sort === true) { + for (var i = 0; i < args.data.length; i++) { + args.data[i].sort(function(a, b) { + return a[args.x_accessor] - b[args.x_accessor]; + }); + } + } + + return this; +} + +function mg_process_multiple_accessors(args, which_accessor) { + // turns an array of accessors into ... + if (mg_is_array(args[which_accessor])) { + args.data = args.data.map(function(_d) { + return args[which_accessor].map(function(ya) { + return _d.map(function(di) { + di = MG.clone(di); + + if (di[ya] === undefined) { + return undefined; + } + + di['multiline_' + which_accessor] = di[ya]; + return di; + }).filter(function(di) { + return di !== undefined; + }); + }); + })[0]; + args[which_accessor] = 'multiline_' + which_accessor; + } +} + +function mg_process_multiple_x_accessors(args) { + mg_process_multiple_accessors(args, 'x_accessor'); +} + +function mg_process_multiple_y_accessors(args) { + mg_process_multiple_accessors(args, 'y_accessor'); +} + +MG.raw_data_transformation = raw_data_transformation; + +function process_line(args) { + 'use strict'; + + var time_frame; + + // do we have a time-series? + var is_time_series = d3.sum(args.data.map(function(series) { + return series.length > 0 && mg_is_date(series[0][args.x_accessor]); + })) > 0; + + // are we replacing missing y values with zeros? + if ((args.missing_is_zero || args.missing_is_hidden) && args.chart_type === 'line' && is_time_series) { + for (var i = 0; i < args.data.length; i++) { + // we need to have a dataset of length > 2, so if it's less than that, skip + if (args.data[i].length <= 1) { + continue; + } + + var first = args.data[i][0]; + var last = args.data[i][args.data[i].length - 1]; + + // initialize our new array for storing the processed data + var processed_data = []; + + // we'll be starting from the day after our first date + var start_date = MG.clone(first[args.x_accessor]).setDate(first[args.x_accessor].getDate() + 1); + + // if we've set a max_x, add data points up to there + var from = (args.min_x) ? args.min_x : start_date; + var upto = (args.max_x) ? args.max_x : last[args.x_accessor]; + + time_frame = mg_get_time_frame((upto - from) / 1000); + + if (['four-days', 'many-days', 'many-months', 'years', 'default'].indexOf(time_frame) !== -1 && args.missing_is_hidden_accessor === null) { + for (var d = new Date(from); d <= upto; d.setDate(d.getDate() + 1)) { + var o = {}; + d.setHours(0, 0, 0, 0); + + // add the first date item, we'll be starting from the day after our first date + if (Date.parse(d) === Date.parse(new Date(start_date))) { + processed_data.push(MG.clone(args.data[i][0])); + } + + // check to see if we already have this date in our data object + var existing_o = null; + args.data[i].forEach(function(val, i) { + if (Date.parse(val[args.x_accessor]) === Date.parse(new Date(d))) { + existing_o = val; + + return false; + } + }); + + // if we don't have this date in our data object, add it and set it to zero + if (!existing_o) { + o[args.x_accessor] = new Date(d); + o[args.y_accessor] = 0; + o['_missing'] = true; //we want to distinguish between zero-value and missing observations + processed_data.push(o); + } + + // if the data point has, say, a 'missing' attribute set or if its + // y-value is null identify it internally as missing + else if (existing_o[args.missing_is_hidden_accessor] || existing_o[args.y_accessor] === null) { + existing_o['_missing'] = true; + processed_data.push(existing_o); + } + + //otherwise, use the existing object for that date + else { + processed_data.push(existing_o); + } + } + } else { + for (var j = 0; j < args.data[i].length; j += 1) { + var obj = MG.clone(args.data[i][j]); + obj['_missing'] = args.data[i][j][args.missing_is_hidden_accessor]; + processed_data.push(obj); + } + } + + // update our date object + args.data[i] = processed_data; + } + } + + return this; +} + +MG.process_line = process_line; + +function process_histogram(args) { + 'use strict'; + + // if args.binned == false, then we need to bin the data appropriately. + // if args.binned == true, then we need to make sure to compute the relevant computed data. + // the outcome of either of these should be something in args.computed_data. + // the histogram plotting function will be looking there for the data to plot. + + // we need to compute an array of objects. + // each object has an x, y, and dx. + + // histogram data is always single dimension + var our_data = args.data[0]; + + var extracted_data; + if (args.binned === false) { + // use d3's built-in layout.histogram functionality to compute what you need. + + if (typeof(our_data[0]) === 'object') { + // we are dealing with an array of objects. Extract the data value of interest. + extracted_data = our_data + .map(function(d) { + return d[args.x_accessor]; + }); + } else if (typeof(our_data[0]) === 'number') { + // we are dealing with a simple array of numbers. No extraction needed. + extracted_data = our_data; + } else { + console.log('TypeError: expected an array of numbers, found ' + typeof(our_data[0])); + return; + } + + var hist = d3.histogram(); + if (args.bins) { + hist.thresholds(args.bins); + } + + var bins = hist(extracted_data); + args.processed_data = bins.map(function(d) { + return { 'x': d.x0, 'y': d.length }; + }); + } else { + // here, we just need to reconstruct the array of objects + // take the x accessor and y accessor. + // pull the data as x and y. y is count. + + args.processed_data = our_data.map(function(d) { + return { 'x': d[args.x_accessor], 'y': d[args.y_accessor] }; + }); + + var this_pt; + var next_pt; + + // we still need to compute the dx component for each data point + for (var i = 0; i < args.processed_data.length; i++) { + this_pt = args.processed_data[i]; + if (i === args.processed_data.length - 1) { + this_pt.dx = args.processed_data[i - 1].dx; + } else { + next_pt = args.processed_data[i + 1]; + this_pt.dx = next_pt.x - this_pt.x; + } + } + } + + // capture the original data and accessors before replacing args.data + if (!args.processed) { + args.processed = {}; + } + args.processed.original_data = args.data; + args.processed.original_x_accessor = args.x_accessor; + args.processed.original_y_accessor = args.y_accessor; + + args.data = [args.processed_data]; + args.x_accessor = args.processed_x_accessor; + args.y_accessor = args.processed_y_accessor; + + return this; +} + +MG.process_histogram = process_histogram; + +// for use with bar charts, etc. +function process_categorical_variables(args) { + 'use strict'; + + var extracted_data, processed_data = {}, + pd = []; + //var our_data = args.data[0]; + var label_accessor = args.bar_orientation === 'vertical' ? args.x_accessor : args.y_accessor; + var data_accessor = args.bar_orientation === 'vertical' ? args.y_accessor : args.x_accessor; + + return this; +} + +MG.process_categorical_variables = process_categorical_variables; + +function process_point(args) { + 'use strict'; + + var data = args.data[0]; + var x = data.map(function(d) { + return d[args.x_accessor]; + }); + var y = data.map(function(d) { + return d[args.y_accessor]; + }); + + if (args.least_squares) { + args.ls_line = least_squares(x, y); + } + + return this; +} + +MG.process_point = process_point; diff --git a/priv/static/js/metricsgraphics/misc/smoothers.js b/priv/static/js/metricsgraphics/misc/smoothers.js new file mode 100644 index 0000000..aab8a00 --- /dev/null +++ b/priv/static/js/metricsgraphics/misc/smoothers.js @@ -0,0 +1,280 @@ +function add_ls(args) { + var svg = mg_get_svg_child_of(args.target); + var data = args.data[0]; + var min_x = d3.min(data, function(d) { + return d[args.x_accessor]; }); + var max_x = d3.max(data, function(d) { + return d[args.x_accessor]; }); + + d3.select(args.target).selectAll('.mg-least-squares-line').remove(); + + svg.append('svg:line') + .attr('x1', args.scales.X(min_x)) + .attr('x2', args.scales.X(max_x)) + .attr('y1', args.scales.Y(args.ls_line.fit(min_x))) + .attr('y2', args.scales.Y(args.ls_line.fit(max_x))) + .attr('class', 'mg-least-squares-line'); +} + +MG.add_ls = add_ls; + +function add_lowess(args) { + var svg = mg_get_svg_child_of(args.target); + var lowess = args.lowess_line; + + var line = d3.svg.line() + .x(function(d) { + return args.scales.X(d.x); }) + .y(function(d) { + return args.scales.Y(d.y); }) + .interpolate(args.interpolate); + + svg.append('path') + .attr('d', line(lowess)) + .attr('class', 'mg-lowess-line'); +} + +MG.add_lowess = add_lowess; + +function lowess_robust(x, y, alpha, inc) { + // Used http://www.unc.edu/courses/2007spring/biol/145/001/docs/lectures/Oct27.html + // for the clear explanation of robust lowess. + + // calculate the the first pass. + var _l; + var r = []; + var yhat = d3.mean(y); + var i; + for (i = 0; i < x.length; i += 1) { r.push(1); } + _l = _calculate_lowess_fit(x, y, alpha, inc, r); + var x_proto = _l.x; + var y_proto = _l.y; + + // Now, take the fit, recalculate the weights, and re-run LOWESS using r*w instead of w. + + for (i = 0; i < 100; i += 1) { + r = d3.zip(y_proto, y).map(function(yi) { + return Math.abs(yi[1] - yi[0]); + }); + + var q = d3.quantile(r.sort(), 0.5); + + r = r.map(function(ri) { + return _bisquare_weight(ri / (6 * q)); + }); + + _l = _calculate_lowess_fit(x, y, alpha, inc, r); + x_proto = _l.x; + y_proto = _l.y; + } + + return d3.zip(x_proto, y_proto).map(function(d) { + var p = {}; + p.x = d[0]; + p.y = d[1]; + return p; + }); +} + +MG.lowess_robust = lowess_robust; + +function lowess(x, y, alpha, inc) { + var r = []; + for (var i = 0; i < x.length; i += 1) { r.push(1); } + var _l = _calculate_lowess_fit(x, y, alpha, inc, r); +} + +MG.lowess = lowess; + +function least_squares(x_, y_) { + var x, y, xi, yi, + _x = 0, + _y = 0, + _xy = 0, + _xx = 0; + + var n = x_.length; + if (mg_is_date(x_[0])) { + x = x_.map(function(d) { + return d.getTime(); + }); + } else { + x = x_; + } + + if (mg_is_date(y_[0])) { + y = y_.map(function(d) { + return d.getTime(); + }); + } else { + y = y_; + } + + var xhat = d3.mean(x); + var yhat = d3.mean(y); + var numerator = 0, + denominator = 0; + + for (var i = 0; i < x.length; i++) { + xi = x[i]; + yi = y[i]; + numerator += (xi - xhat) * (yi - yhat); + denominator += (xi - xhat) * (xi - xhat); + } + + var beta = numerator / denominator; + var x0 = yhat - beta * xhat; + + return { + x0: x0, + beta: beta, + fit: function(x) { + return x0 + x * beta; + } + }; +} + +MG.least_squares = least_squares; + +function _pow_weight(u, w) { + if (u >= 0 && u <= 1) { + return Math.pow(1 - Math.pow(u, w), w); + } else { + return 0; + } +} + +function _bisquare_weight(u) { + return _pow_weight(u, 2); +} + +function _tricube_weight(u) { + return _pow_weight(u, 3); +} + +function _neighborhood_width(x0, xis) { + return Array.max(xis.map(function(xi) { + return Math.abs(x0 - xi); + })); +} + +function _manhattan(x1, x2) { + return Math.abs(x1 - x2); +} + +function _weighted_means(wxy) { + var wsum = d3.sum(wxy.map(function(wxyi) { + return wxyi.w; })); + + return { + xbar: d3.sum(wxy.map(function(wxyi) { + return wxyi.w * wxyi.x; + })) / wsum, + ybar: d3.sum(wxy.map(function(wxyi) { + return wxyi.w * wxyi.y; + })) / wsum + }; +} + +function _weighted_beta(wxy, xbar, ybar) { + var num = d3.sum(wxy.map(function(wxyi) { + return Math.pow(wxyi.w, 2) * (wxyi.x - xbar) * (wxyi.y - ybar); + })); + + var denom = d3.sum(wxy.map(function(wxyi) { + return Math.pow(wxyi.w, 2) * Math.pow(wxyi.x - xbar, 2); + })); + + return num / denom; +} + +function _weighted_least_squares(wxy) { + var ybar, xbar, beta_i, x0; + + var _wm = _weighted_means(wxy); + + xbar = _wm.xbar; + ybar = _wm.ybar; + + var beta = _weighted_beta(wxy, xbar, ybar); + + return { + beta: beta, + xbar: xbar, + ybar: ybar, + x0: ybar - beta * xbar + + }; +} + +function _calculate_lowess_fit(x, y, alpha, inc, residuals) { + // alpha - smoothing factor. 0 < alpha < 1/ + // + // + var k = Math.floor(x.length * alpha); + + var sorted_x = x.slice(); + + sorted_x.sort(function(a, b) { + if (a < b) { + return -1; } else if (a > b) { + return 1; } + + return 0; + }); + + var x_max = d3.quantile(sorted_x, 0.98); + var x_min = d3.quantile(sorted_x, 0.02); + + var xy = d3.zip(x, y, residuals).sort(); + + var size = Math.abs(x_max - x_min) / inc; + + var smallest = x_min; + var largest = x_max; + var x_proto = d3.range(smallest, largest, size); + + var xi_neighbors; + var x_i, beta_i, x0_i, delta_i, xbar, ybar; + + // for each prototype, find its fit. + var y_proto = []; + + for (var i = 0; i < x_proto.length; i += 1) { + x_i = x_proto[i]; + + // get k closest neighbors. + xi_neighbors = xy.map(function(xyi) { + return [ + Math.abs(xyi[0] - x_i), + xyi[0], + xyi[1], + xyi[2] + ]; + }).sort().slice(0, k); + + // Get the largest distance in the neighbor set. + delta_i = d3.max(xi_neighbors)[0]; + + // Prepare the weights for mean calculation and WLS. + + xi_neighbors = xi_neighbors.map(function(wxy) { + return { + w: _tricube_weight(wxy[0] / delta_i) * wxy[3], + x: wxy[1], + y: wxy[2] + }; + }); + + // Find the weighted least squares, obviously. + var _output = _weighted_least_squares(xi_neighbors); + + x0_i = _output.x0; + beta_i = _output.beta; + + // + y_proto.push(x0_i + beta_i * x_i); + } + + return { x: x_proto, y: y_proto }; +} diff --git a/priv/static/js/metricsgraphics/misc/transitions.js b/priv/static/js/metricsgraphics/misc/transitions.js new file mode 100644 index 0000000..da1e503 --- /dev/null +++ b/priv/static/js/metricsgraphics/misc/transitions.js @@ -0,0 +1,31 @@ +// http://bl.ocks.org/mbostock/3916621 +function path_tween(d1, precision) { + return function() { + var path0 = this, + path1 = path0.cloneNode(), + n0 = path0.getTotalLength() || 0, + n1 = (path1.setAttribute("d", d1), path1).getTotalLength() || 0; + + // Uniform sampling of distance based on specified precision. + var distances = [0], + i = 0, + dt = precision / Math.max(n0, n1); + while ((i += dt) < 1) distances.push(i); + distances.push(1); + + // Compute point-interpolators at each distance. + var points = distances.map(function(t) { + var p0 = path0.getPointAtLength(t * n0), + p1 = path1.getPointAtLength(t * n1); + return d3.interpolate([p0.x, p0.y], [p1.x, p1.y]); + }); + + return function(t) { + return t < 1 ? "M" + points.map(function(p) { + return p(t); + }).join("L") : d1; + }; + }; +} + +MG.path_tween = path_tween; diff --git a/priv/static/js/metricsgraphics/misc/utility.js b/priv/static/js/metricsgraphics/misc/utility.js new file mode 100644 index 0000000..f68327b --- /dev/null +++ b/priv/static/js/metricsgraphics/misc/utility.js @@ -0,0 +1,619 @@ +//a set of helper functions, some that we've written, others that we've borrowed + +MG.convert = {}; + +MG.convert.date = function(data, accessor, time_format) { + time_format = (typeof time_format === "undefined") ? '%Y-%m-%d' : time_format; + var parse_time = d3.timeParse(time_format); + data = data.map(function(d) { + d[accessor] = parse_time(d[accessor].trim()); + return d; + }); + + return data; +}; + +MG.convert.number = function(data, accessor) { + data = data.map(function(d) { + d[accessor] = Number(d[accessor]); + return d; + }); + + return data; +}; + +MG.time_format = function(utc, specifier) { + return utc ? d3.utcFormat(specifier) : d3.timeFormat(specifier); +}; + +function mg_jquery_exists() { + if (typeof jQuery !== 'undefined' || typeof $ !== 'undefined') { + return true; + } else { + return false; + } +} + +function mg_get_rollover_time_format(args) { + // if a rollover time format is defined, use that + if (args.rollover_time_format) { + return MG.time_format(args.utc_time, args.rollover_time_format); + } + + switch (args.processed.x_time_frame) { + case 'millis': + return MG.time_format(args.utc_time, '%b %e, %Y %H:%M:%S.%L'); + case 'seconds': + return MG.time_format(args.utc_time, '%b %e, %Y %H:%M:%S'); + case 'less-than-a-day': + return MG.time_format(args.utc_time, '%b %e, %Y %I:%M%p'); + case 'four-days': + return MG.time_format(args.utc_time, '%b %e, %Y %I:%M%p'); + } + + // default + return MG.time_format(args.utc_time, '%b %e, %Y'); +} + +function mg_data_in_plot_bounds(datum, args) { + return datum[args.x_accessor] >= args.processed.min_x && + datum[args.x_accessor] <= args.processed.max_x && + datum[args.y_accessor] >= args.processed.min_y && + datum[args.y_accessor] <= args.processed.max_y; +} + +function is_array(thing) { + return Object.prototype.toString.call(thing) === '[object Array]'; +} + +function is_function(thing) { + return Object.prototype.toString.call(thing) === '[object Function]'; +} + +function is_empty_array(thing) { + return is_array(thing) && thing.length === 0; +} + +function is_object(thing) { + return Object.prototype.toString.call(thing) === '[object Object]'; +} + +function is_array_of_arrays(data) { + var all_elements = data.map(function(d) { + return is_array(d) === true && d.length > 0; + }); + + return d3.sum(all_elements) === data.length; +} + +function is_array_of_objects(data) { + // is every element of data an object? + var all_elements = data.map(function(d) { + return is_object(d) === true; + }); + + return d3.sum(all_elements) === data.length; +} + +function is_array_of_objects_or_empty(data) { + return is_empty_array(data) || is_array_of_objects(data); +} + +function pluck(arr, accessor) { + return arr.map(function(d) { + return d[accessor]; }); +} + +function count_array_elements(arr) { + return arr.reduce(function(a, b) { a[b] = a[b] + 1 || 1; + return a; }, {}); +} + +function mg_get_bottom(args) { + return args.height - args.bottom; +} + +function mg_get_plot_bottom(args) { + // returns the pixel location of the bottom side of the plot area. + return mg_get_bottom(args) - args.buffer; +} + +function mg_get_top(args) { + return args.top; +} + +function mg_get_plot_top(args) { + // returns the pixel location of the top side of the plot area. + return mg_get_top(args) + args.buffer; +} + +function mg_get_left(args) { + return args.left; +} + +function mg_get_plot_left(args) { + // returns the pixel location of the left side of the plot area. + return mg_get_left(args) + args.buffer; +} + +function mg_get_right(args) { + return args.width - args.right; +} + +function mg_get_plot_right(args) { + // returns the pixel location of the right side of the plot area. + return mg_get_right(args) - args.buffer; +} + +//////// adding elements, removing elements ///////////// + +function mg_exit_and_remove(elem) { + elem.exit().remove(); +} + +function mg_selectAll_and_remove(svg, cl) { + svg.selectAll(cl).remove(); +} + +function mg_add_g(svg, cl) { + return svg.append('g').classed(cl, true); +} + +function mg_remove_element(svg, elem) { + svg.select(elem).remove(); +} + +//////// axis helper functions //////////// + +function mg_make_rug(args, rug_class) { + var svg = mg_get_svg_child_of(args.target); + var all_data = mg_flatten_array(args.data); + var rug = svg.selectAll('line.' + rug_class).data(all_data); + + rug.enter() + .append('line') + .attr('class', rug_class) + .attr('opacity', 0.3); + + //remove rug elements that are no longer in use + mg_exit_and_remove(rug); + + //set coordinates of new rug elements + mg_exit_and_remove(rug); + return rug; +} + +function mg_add_color_accessor_to_rug(rug, args, rug_mono_class) { + if (args.color_accessor) { + rug.attr('stroke', args.scalefns.colorf); + rug.classed(rug_mono_class, false); + } else { + rug.attr('stroke', null); + rug.classed(rug_mono_class, true); + } +} + +function mg_rotate_labels(labels, rotation_degree) { + if (rotation_degree) { + labels.attr({ + dy: 0, + transform: function() { + var elem = d3.select(this); + return 'rotate(' + rotation_degree + ' ' + elem.attr('x') + ',' + elem.attr('y') + ')'; + } + }); + } +} + +////////////////////////////////////////////////// + +function mg_elements_are_overlapping(labels) { + labels = labels.node(); + if (!labels) { + return false; + } + + for (var i = 0; i < labels.length; i++) { + if (mg_is_horizontally_overlapping(labels[i], labels)) return true; + } + + return false; +} + +function mg_prevent_horizontal_overlap(labels, args) { + if (!labels || labels.length == 1) { + return; + } + + //see if each of our labels overlaps any of the other labels + for (var i = 0; i < labels.length; i++) { + //if so, nudge it up a bit, if the label it intersects hasn't already been nudged + if (mg_is_horizontally_overlapping(labels[i], labels)) { + var node = d3.select(labels[i]); + var newY = +node.attr('y'); + if (newY + 8 >= args.top) { + newY = args.top - 16; + } + node.attr('y', newY); + } + } +} + +function mg_prevent_vertical_overlap(labels, args) { + if (!labels || labels.length == 1) { + return; + } + + labels.sort(function(b, a) { + return d3.select(a).attr('y') - d3.select(b).attr('y'); + }); + + labels.reverse(); + + var overlap_amount, label_i, label_j; + + //see if each of our labels overlaps any of the other labels + for (var i = 0; i < labels.length; i++) { + //if so, nudge it up a bit, if the label it intersects hasn't already been nudged + label_i = d3.select(labels[i]).text(); + + for (var j = 0; j < labels.length; j++) { + label_j = d3.select(labels[j]).text(); + overlap_amount = mg_is_vertically_overlapping(labels[i], labels[j]); + + if (overlap_amount !== false && label_i !== label_j) { + var node = d3.select(labels[i]); + var newY = +node.attr('y'); + newY = newY + overlap_amount; + node.attr('y', newY); + } + } + } +} + +function mg_is_vertically_overlapping(element, sibling) { + var element_bbox = element.getBoundingClientRect(); + var sibling_bbox = sibling.getBoundingClientRect(); + + if (element_bbox.top <= sibling_bbox.bottom && element_bbox.top >= sibling_bbox.top) { + return sibling_bbox.bottom - element_bbox.top; + } + + return false; +} + +function mg_is_horiz_overlap(element, sibling) { + var element_bbox = element.getBoundingClientRect(); + var sibling_bbox = sibling.getBoundingClientRect(); + + if (element_bbox.right >= sibling_bbox.left || element_bbox.top >= sibling_bbox.top) { + return sibling_bbox.bottom - element_bbox.top; + } + return false; +} + +function mg_is_horizontally_overlapping(element, labels) { + var element_bbox = element.getBoundingClientRect(); + + for (var i = 0; i < labels.length; i++) { + if (labels[i] == element) { + continue; + } + + //check to see if this label overlaps with any of the other labels + var sibling_bbox = labels[i].getBoundingClientRect(); + if (element_bbox.top === sibling_bbox.top && + !(sibling_bbox.left > element_bbox.right || sibling_bbox.right < element_bbox.left) + ) { + return true; + } + } + + return false; +} + +function mg_infer_type(args, ns) { + // must return categorical or numerical. + var testPoint = mg_flatten_array(args.data); + + testPoint = testPoint[0][args[ns + '_accessor']]; + return typeof testPoint === 'string' ? 'categorical' : 'numerical'; + } + +function mg_get_svg_child_of(selector_or_node) { + return d3.select(selector_or_node).select('svg'); +} + +function mg_flatten_array(arr) { + var flat_data = []; + return flat_data.concat.apply(flat_data, arr); +} + +function mg_next_id() { + if (typeof MG._next_elem_id === 'undefined') { + MG._next_elem_id = 0; + } + + return 'mg-' + (MG._next_elem_id++); +} + +function mg_target_ref(target) { + if (typeof target === 'string') { + return mg_normalize(target); + + } else if (target instanceof window.HTMLElement) { + var target_ref = target.getAttribute('data-mg-uid'); + if (!target_ref) { + target_ref = mg_next_id(); + target.setAttribute('data-mg-uid', target_ref); + } + + return target_ref; + + } else { + console.warn('The specified target should be a string or an HTMLElement.', target); + return mg_normalize(target); + } +} + +function mg_normalize(string) { + return string + .replace(/[^a-zA-Z0-9 _-]+/g, '') + .replace(/ +?/g, ''); +} + +function get_pixel_dimension(target, dimension) { + return Number(d3.select(target).style(dimension).replace(/px/g, '')); +} + +function get_width(target) { + return get_pixel_dimension(target, 'width'); +} + +function get_height(target) { + return get_pixel_dimension(target, 'height'); +} + +function isNumeric(n) { + return !isNaN(parseFloat(n)) && isFinite(n); +} + +var each = function(obj, iterator, context) { + // yanked out of underscore + var breaker = {}; + if (obj === null) return obj; + if (Array.prototype.forEach && obj.forEach === Array.prototype.forEach) { + obj.forEach(iterator, context); + } else if (obj.length === +obj.length) { + for (var i = 0, length = obj.length; i < length; i++) { + if (iterator.call(context, obj[i], i, obj) === breaker) return; + } + } else { + for (var k in obj) { + if (iterator.call(context, obj[k], k, obj) === breaker) return; + } + } + + return obj; +}; + +function merge_with_defaults(obj) { + // taken from underscore + each(Array.prototype.slice.call(arguments, 1), function(source) { + if (source) { + for (var prop in source) { + if (obj[prop] === void 0) obj[prop] = source[prop]; + } + } + }); + + return obj; +} + +MG.merge_with_defaults = merge_with_defaults; + +function options_to_defaults(obj) { + return Object.keys(obj).reduce((r, k) => { + r[k] = obj[k][0]; + return r; + }, {}); +} + +function compare_type(type, value) { + if (value == null) return true; // allow null or undefined + if (typeof type === 'string') { + if (type.substr(-2) === '[]') { + if (!is_array(value)) return false; + return value.every(i => compare_type(type.slice(0, -2), i)); + } + return typeof value === type + || value === type + || type.length === 0 + || type === 'array' && is_array(value); + } + if (typeof type === 'function') return value === type || value instanceof type; + return is_array(type) && !!~type.findIndex(i => compare_type(i, value)); +} + +function mg_validate_option(key, value) { + if (!is_array(MG.options[key])) return false; // non-existent option + const typeDef = MG.options[key][1]; + if (!typeDef) return true; // not restricted type + return compare_type(typeDef, value); +} + +function number_of_values(data, accessor, value) { + var values = data.filter(function(d) { + return d[accessor] === value; + }); + + return values.length; +} + +function has_values_below(data, accessor, value) { + var values = data.filter(function(d) { + return d[accessor] <= value; + }); + + return values.length > 0; +} + +function has_too_many_zeros(data, accessor, zero_count) { + return number_of_values(data, accessor, 0) >= zero_count; +} + +function mg_is_date(obj) { + return Object.prototype.toString.call(obj) === '[object Date]'; +} + +function mg_is_object(obj) { + return Object.prototype.toString.call(obj) === '[object Object]'; +} + +function mg_is_array(obj) { + if (Array.isArray) { + return Array.isArray(obj); + } + + return Object.prototype.toString.call(obj) === '[object Array]'; +} + +function mg_is_function(obj) { + return Object.prototype.toString.call(obj) === '[object Function]'; +} + +// deep copy +// http://stackoverflow.com/questions/728360/most-elegant-way-to-clone-a-javascript-object +MG.clone = function(obj) { + var copy; + + // Handle the 3 simple types, and null or undefined + if (null === obj || "object" !== typeof obj) return obj; + + // Handle Date + if (mg_is_date(obj)) { + copy = new Date(); + copy.setTime(obj.getTime()); + return copy; + } + + // Handle Array + if (mg_is_array(obj)) { + copy = []; + for (var i = 0, len = obj.length; i < len; i++) { + copy[i] = MG.clone(obj[i]); + } + return copy; + } + + // Handle Object + if (mg_is_object(obj)) { + copy = {}; + for (var attr in obj) { + if (obj.hasOwnProperty(attr)) copy[attr] = MG.clone(obj[attr]); + } + return copy; + } + + throw new Error("Unable to copy obj! Its type isn't supported."); +}; + +// give us the difference of two int arrays +// http://radu.cotescu.com/javascript-diff-function/ +function arr_diff(a, b) { + var seen = [], + diff = [], + i; + for (i = 0; i < b.length; i++) + seen[b[i]] = true; + for (i = 0; i < a.length; i++) + if (!seen[a[i]]) + diff.push(a[i]); + return diff; +} + +MG.arr_diff = arr_diff; + +/** + Print warning message to the console when a feature has been scheduled for removal + + @author Dan de Havilland (github.com/dandehavilland) + @date 2014-12 +*/ +function warn_deprecation(message, untilVersion) { + console.warn('Deprecation: ' + message + (untilVersion ? '. This feature will be removed in ' + untilVersion + '.' : ' the near future.')); + console.trace(); +} + +MG.warn_deprecation = warn_deprecation; + +/** + Truncate a string to fit within an SVG text node + CSS text-overlow doesn't apply to SVG <= 1.2 + + @author Dan de Havilland (github.com/dandehavilland) + @date 2014-12-02 +*/ +function truncate_text(textObj, textString, width) { + var bbox, + position = 0; + + textObj.textContent = textString; + bbox = textObj.getBBox(); + + while (bbox.width > width) { + textObj.textContent = textString.slice(0, --position) + '...'; + bbox = textObj.getBBox(); + + if (textObj.textContent === '...') { + break; + } + } +} + +MG.truncate_text = truncate_text; + +/** + Wrap the contents of a text node to a specific width + + Adapted from bl.ocks.org/mbostock/7555321 + + @author Mike Bostock + @author Dan de Havilland + @date 2015-01-14 +*/ +function wrap_text(text, width, token, tspanAttrs) { + text.each(function() { + var text = d3.select(this), + words = text.text().split(token || /\s+/).reverse(), + word, + line = [], + lineNumber = 0, + lineHeight = 1.1, // ems + y = text.attr("y"), + dy = 0, + tspan = text.text(null) + .append("tspan") + .attr("x", 0) + .attr("y", dy + "em") + .attr(tspanAttrs || {}); + + while (!!(word = words.pop())) { + line.push(word); + tspan.text(line.join(" ")); + if (width === null || tspan.node().getComputedTextLength() > width) { + line.pop(); + tspan.text(line.join(" ")); + line = [word]; + tspan = text + .append("tspan") + .attr("x", 0) + .attr("y", ++lineNumber * lineHeight + dy + "em") + .attr(tspanAttrs || {}) + .text(word); + } + } + }); +} + +MG.wrap_text = wrap_text; |