diff options
Diffstat (limited to 'priv/static/js/metricsgraphics/charts')
-rw-r--r-- | priv/static/js/metricsgraphics/charts/bar.js | 792 | ||||
-rw-r--r-- | priv/static/js/metricsgraphics/charts/histogram.js | 222 | ||||
-rw-r--r-- | priv/static/js/metricsgraphics/charts/line.js | 922 | ||||
-rw-r--r-- | priv/static/js/metricsgraphics/charts/missing.js | 144 | ||||
-rw-r--r-- | priv/static/js/metricsgraphics/charts/point.js | 383 | ||||
-rw-r--r-- | priv/static/js/metricsgraphics/charts/table.js | 220 |
6 files changed, 2683 insertions, 0 deletions
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; +}; |