1482 lines
35 KiB
JavaScript
1482 lines
35 KiB
JavaScript
// specific_values = [
|
|
// {
|
|
// name: "Average",
|
|
// line_type: "dashed", // "dashed" or "solid"
|
|
// value: 10
|
|
// },
|
|
|
|
// summary = [
|
|
// {
|
|
// name: "Total",
|
|
// color: 'blue', // Indicator colors: 'grey', 'blue', 'red', 'green', 'orange',
|
|
// // 'purple', 'darkgrey', 'black', 'yellow', 'lightblue'
|
|
// value: 80
|
|
// }
|
|
// ]
|
|
|
|
// Validate all arguments, check passed data format, set defaults
|
|
|
|
class FrappeChart {
|
|
constructor({
|
|
parent = "",
|
|
height = 240,
|
|
|
|
title = '', subtitle = '',
|
|
|
|
data = {},
|
|
format_lambdas = {},
|
|
|
|
specific_values = [],
|
|
summary = [],
|
|
|
|
is_navigable = 0,
|
|
|
|
type = ''
|
|
}) {
|
|
if(Object.getPrototypeOf(this) === FrappeChart.prototype) {
|
|
if(type === 'line') {
|
|
return new LineGraph(arguments[0]);
|
|
} else if(type === 'bar') {
|
|
return new BarGraph(arguments[0]);
|
|
} else if(type === 'percentage') {
|
|
return new PercentageGraph(arguments[0]);
|
|
} else if(type === 'heatmap') {
|
|
return new HeatMap(arguments[0]);
|
|
}
|
|
}
|
|
|
|
this.parent = document.querySelector(parent);
|
|
this.set_margins(height);
|
|
|
|
this.title = title;
|
|
this.subtitle = subtitle;
|
|
|
|
this.data = data;
|
|
this.format_lambdas = format_lambdas;
|
|
|
|
this.specific_values = specific_values;
|
|
this.summary = summary;
|
|
|
|
this.is_navigable = is_navigable;
|
|
if(this.is_navigable) {
|
|
this.current_index = 0;
|
|
}
|
|
}
|
|
|
|
set_margins(height) {
|
|
this.base_height = height;
|
|
this.height = height - 40;
|
|
this.translate_x = 60;
|
|
this.translate_y = 10;
|
|
}
|
|
|
|
setup() {
|
|
this.bind_window_events();
|
|
this.refresh();
|
|
}
|
|
|
|
bind_window_events() {
|
|
window.addEventListener('resize', () => this.refresh());
|
|
window.addEventListener('orientationchange', () => this.refresh());
|
|
}
|
|
|
|
refresh() {
|
|
this.setup_base_values();
|
|
this.set_width();
|
|
|
|
this.setup_container();
|
|
this.setup_components();
|
|
|
|
this.setup_values();
|
|
this.setup_utils();
|
|
|
|
this.make_graph_components();
|
|
this.make_tooltip();
|
|
|
|
if(this.summary.length > 0) {
|
|
this.show_custom_summary();
|
|
} else {
|
|
this.show_summary();
|
|
}
|
|
|
|
if(this.is_navigable) {
|
|
this.setup_navigation();
|
|
}
|
|
}
|
|
|
|
set_width() {
|
|
this.base_width = this.parent.offsetWidth;
|
|
this.width = this.base_width - this.translate_x * 2;
|
|
}
|
|
|
|
setup_base_values() {}
|
|
|
|
setup_container() {
|
|
this.container = $$.create('div', {
|
|
className: 'graph-container',
|
|
innerHTML: `<h6 class="title" style="margin-top: 15px;">${this.title}</h6>
|
|
<h6 class="sub-title uppercase">${this.subtitle}</h6>
|
|
<div class="frappe-chart graphics"></div>
|
|
<div class="graph-stats-container"></div>`
|
|
});
|
|
|
|
// Chart needs a dedicated parent element
|
|
this.parent.innerHTML = '';
|
|
this.parent.appendChild(this.container);
|
|
|
|
this.chart_wrapper = this.container.querySelector('.frappe-chart');
|
|
this.chart_wrapper.appendChild(this.make_graph_area());
|
|
|
|
this.stats_wrapper = this.container.querySelector('.graph-stats-container');
|
|
}
|
|
|
|
make_graph_area() {
|
|
this.svg = $$.createSVG('svg', {
|
|
className: 'chart',
|
|
width: this.base_width,
|
|
height: this.base_height
|
|
});
|
|
|
|
return this.svg;
|
|
}
|
|
|
|
setup_components() {
|
|
this.svg_units = $$.createSVG('g', {className: 'data-points'});
|
|
}
|
|
|
|
make_tooltip() {
|
|
this.tip = new SvgTip({
|
|
parent: this.chart_wrapper,
|
|
});
|
|
this.bind_tooltip();
|
|
}
|
|
|
|
show_summary() {}
|
|
show_custom_summary() {
|
|
this.summary.map(d => {
|
|
let stats = $$.create('div', {
|
|
className: 'stats',
|
|
innerHTML: `<span class="indicator ${d.color}">${d.name}: ${d.value}</span>`
|
|
});
|
|
this.stats_wrapper.appendChild(stats);
|
|
});
|
|
}
|
|
|
|
setup_navigation() {
|
|
this.make_overlay();
|
|
this.bind_overlay();
|
|
document.onkeydown = (e) => {
|
|
e = e || window.event;
|
|
|
|
if (e.keyCode == '37') {
|
|
this.on_left_arrow();
|
|
} else if (e.keyCode == '39') {
|
|
this.on_right_arrow();
|
|
} else if (e.keyCode == '38') {
|
|
this.on_up_arrow();
|
|
} else if (e.keyCode == '40') {
|
|
this.on_down_arrow();
|
|
} else if (e.keyCode == '13') {
|
|
this.on_enter_key();
|
|
}
|
|
}
|
|
}
|
|
|
|
make_overlay() {}
|
|
bind_overlay() {}
|
|
|
|
on_left_arrow() {}
|
|
on_right_arrow() {}
|
|
on_up_arrow() {}
|
|
on_down_arrow() {}
|
|
on_enter_key() {}
|
|
|
|
get_data_point(index=this.current_index) {
|
|
// check for length
|
|
let data_point = {
|
|
index: index
|
|
}
|
|
let y = this.y[0];
|
|
['svg_units', 'y_tops', 'values'].map(key => {
|
|
let data_key = key.slice(0, key.length-1);
|
|
data_point[data_key] = y[key][index];
|
|
});
|
|
data_point.label = this.x[index];
|
|
return data_point;
|
|
}
|
|
|
|
update_current_data_point(index) {
|
|
if(index < 0) index = 0;
|
|
if(index >= this.x.length) index = this.x.length - 1;
|
|
if(index === this.current_index) return;
|
|
this.current_index = index;
|
|
$$.fire(this.parent, "data-select", this.get_data_point());
|
|
}
|
|
|
|
// Helpers
|
|
get_strwidth(string) {
|
|
return string.length * 8;
|
|
}
|
|
|
|
// Objects
|
|
setup_utils() {
|
|
this.draw = {
|
|
'bar': (x, y, args, color, index) => {
|
|
let total_width = this.avg_unit_width - args.space_width;
|
|
let start_x = x - total_width/2;
|
|
|
|
let width = total_width / args.no_of_datasets;
|
|
let current_x = start_x + width * index;
|
|
if(y == this.height) {
|
|
y = this.height * 0.98;
|
|
}
|
|
return $$.createSVG('rect', {
|
|
className: `bar mini fill ${color}`,
|
|
x: current_x,
|
|
y: y,
|
|
width: width,
|
|
height: this.height - y
|
|
});
|
|
|
|
},
|
|
'dot': (x, y, args, color) => {
|
|
return $$.createSVG('circle', {
|
|
className: `fill ${color}`,
|
|
cx: x,
|
|
cy: y,
|
|
r: args.radius
|
|
});
|
|
}
|
|
};
|
|
|
|
this.animate = {
|
|
'bar': (bar, new_y, args) => {
|
|
return [bar, {height: args.new_height, y: new_y}, 300, "easein"];
|
|
// bar.animate({height: args.new_height, y: new_y}, 300, mina.easein);
|
|
},
|
|
'dot': (dot, new_y) => {
|
|
return [dot, {cy: new_y}, 300, "easein"];
|
|
// dot.animate({cy: new_y}, 300, mina.easein);
|
|
}
|
|
};
|
|
}
|
|
}
|
|
|
|
class AxisGraph extends FrappeChart {
|
|
constructor(args) {
|
|
super(args);
|
|
|
|
this.x = this.data.labels;
|
|
this.y = this.data.datasets;
|
|
|
|
this.get_x_label = this.format_lambdas.x_label;
|
|
this.get_y_label = this.format_lambdas.y_label;
|
|
this.get_x_tooltip = this.format_lambdas.x_tooltip;
|
|
this.get_y_tooltip = this.format_lambdas.y_tooltip;
|
|
|
|
}
|
|
|
|
setup_values() {
|
|
this.setup_x();
|
|
this.setup_y();
|
|
}
|
|
|
|
setup_x() {
|
|
this.set_avg_unit_width_and_x_offset();
|
|
this.x_axis_values = this.x.map((d, i) => chart_utils.float_2(this.x_offset + i * this.avg_unit_width));
|
|
}
|
|
|
|
setup_y() {
|
|
// Metrics: upper limit, no. of parts, multiplier
|
|
this.setup_metrics();
|
|
|
|
// Axis values
|
|
this.y_axis_values = this.get_y_axis_values(this.upper_limit, this.parts);
|
|
|
|
// Data points
|
|
this.y.map(d => {
|
|
d.y_tops = d.values.map( val => chart_utils.float_2(this.height - val * this.multiplier));
|
|
d.svg_units = [];
|
|
});
|
|
|
|
this.calc_min_tops();
|
|
}
|
|
|
|
get_all_y_values() {
|
|
let all_values = [];
|
|
this.y.map(d => {
|
|
all_values = all_values.concat(d.values);
|
|
});
|
|
return all_values.concat(this.specific_values.map(d => d.value));
|
|
}
|
|
|
|
setup_metrics() {
|
|
let values = this.get_all_y_values();
|
|
[this.upper_limit, this.parts] = this.get_upper_limit_and_parts(values);
|
|
this.multiplier = this.height / this.upper_limit;
|
|
}
|
|
|
|
setup_components() {
|
|
super.setup_components();
|
|
this.y_axis_group = $$.createSVG('g', {className: 'y axis'});
|
|
this.x_axis_group = $$.createSVG('g', {className: 'x axis'});
|
|
this.specific_y_lines = $$.createSVG('g', {className: 'specific axis'});
|
|
}
|
|
|
|
make_graph_components() {
|
|
this.make_y_axis();
|
|
this.make_x_axis();
|
|
|
|
this.y_colors = ['lightblue', 'purple', 'blue', 'green', 'lightgreen',
|
|
'yellow', 'orange', 'red']
|
|
|
|
this.y.map((d, i) => {
|
|
this.make_units(d.y_tops, d.color || this.y_colors[i], i);
|
|
this.make_path && this.make_path(d, d.color || this.y_colors[i]);
|
|
});
|
|
|
|
if(this.specific_values.length > 0) {
|
|
this.show_specific_values();
|
|
}
|
|
|
|
this.setup_group();
|
|
}
|
|
|
|
// make HORIZONTAL lines for y values
|
|
make_y_axis() {
|
|
let width, text_end_at = -9, label_class = '', start_at = 0;
|
|
if(this.y_axis_mode === 'span') { // long spanning lines
|
|
width = this.width + 6;
|
|
start_at = -6;
|
|
} else if(this.y_axis_mode === 'tick'){ // short label lines
|
|
width = -6;
|
|
label_class = 'y-axis-label';
|
|
}
|
|
|
|
this.y_axis_values.map((point) => {
|
|
let line = $$.createSVG('line', {
|
|
x1: start_at,
|
|
x2: width,
|
|
y1: 0,
|
|
y2: 0
|
|
});
|
|
let text = $$.createSVG('text', {
|
|
className: 'y-value-text',
|
|
x: text_end_at,
|
|
y: 0,
|
|
dy: '.32em',
|
|
innerHTML: point+""
|
|
});
|
|
|
|
let y_level = $$.createSVG('g', {
|
|
className: `tick ${label_class}`,
|
|
transform: `translate(0, ${this.height - point * this.multiplier })`
|
|
});
|
|
|
|
y_level.appendChild(line);
|
|
y_level.appendChild(text);
|
|
|
|
this.y_axis_group.appendChild(y_level);
|
|
});
|
|
}
|
|
|
|
// make VERTICAL lines for x values
|
|
make_x_axis() {
|
|
let start_at, height, text_start_at, label_class = '';
|
|
if(this.x_axis_mode === 'span') { // long spanning lines
|
|
start_at = -7;
|
|
height = this.height + 15;
|
|
text_start_at = this.height + 25;
|
|
} else if(this.x_axis_mode === 'tick'){ // short label lines
|
|
start_at = this.height;
|
|
height = 6;
|
|
text_start_at = 9;
|
|
label_class = 'x-axis-label';
|
|
}
|
|
|
|
this.x_axis_group.setAttribute('transform', `translate(0,${start_at})`);
|
|
|
|
this.x.map((point, i) => {
|
|
let allowed_space = this.avg_unit_width * 1.5;
|
|
if(this.get_strwidth(point) > allowed_space) {
|
|
let allowed_letters = allowed_space / 8;
|
|
point = point.slice(0, allowed_letters-3) + " ...";
|
|
}
|
|
|
|
let line = $$.createSVG('line', {
|
|
x1: 0,
|
|
x2: 0,
|
|
y1: 0,
|
|
y2: height
|
|
});
|
|
let text = $$.createSVG('text', {
|
|
className: 'x-value-text',
|
|
x: 0,
|
|
y: text_start_at,
|
|
dy: '.71em',
|
|
innerHTML: point
|
|
});
|
|
|
|
let x_level = $$.createSVG('g', {
|
|
className: `tick ${label_class}`,
|
|
transform: `translate(${ this.x_axis_values[i] }, 0)`
|
|
});
|
|
|
|
x_level.appendChild(line);
|
|
x_level.appendChild(text);
|
|
|
|
this.x_axis_group.appendChild(x_level);
|
|
});
|
|
}
|
|
|
|
make_units(y_values, color, dataset_index) {
|
|
let d = this.unit_args;
|
|
y_values.map((y, i) => {
|
|
let data_unit = this.draw[d.type](
|
|
this.x_axis_values[i],
|
|
y,
|
|
d.args,
|
|
color,
|
|
dataset_index
|
|
);
|
|
this.svg_units.appendChild(data_unit);
|
|
this.y[dataset_index].svg_units.push(data_unit);
|
|
});
|
|
}
|
|
|
|
show_specific_values() {
|
|
this.specific_values.map(d => {
|
|
let line = $$.createSVG('line', {
|
|
className: d.line_type === "dashed" ? "dashed": "",
|
|
x1: 0,
|
|
x2: 0,
|
|
y1: this.width,
|
|
y2: 0
|
|
});
|
|
|
|
let text = $$.createSVG('text', {
|
|
className: 'specific-value',
|
|
x: this.width + 5,
|
|
y: 0,
|
|
dy: '.32em',
|
|
innerHTML: d.name.toUpperCase()
|
|
});
|
|
|
|
let specific_y_level = $$.createSVG('g', {
|
|
className: `tick`,
|
|
transform: `translate(0, ${this.height - d.value * this.multiplier })`
|
|
});
|
|
|
|
specific_y_level.appendChild(line);
|
|
specific_y_level.appendChild(text);
|
|
|
|
this.specific_y_lines.appendChild(specific_y_level);
|
|
});
|
|
}
|
|
|
|
// translates everything with predefined x and y: TODO: make generic
|
|
setup_group() {
|
|
this.chart_group = $$.createSVG("g", {
|
|
className: this.type,
|
|
inside: this.svg,
|
|
transform: `translate(${this.translate_x}, ${this.translate_y})`
|
|
});
|
|
let all_components = [this.y_axis_group,
|
|
this.x_axis_group,
|
|
this.svg_units,
|
|
this.specific_y_lines]
|
|
|
|
all_components.map(c => this.chart_group.appendChild(c));
|
|
}
|
|
|
|
bind_tooltip() {
|
|
// should be w.r.t. this.parent, but will have to take care of
|
|
// all the elements and padding, margins on top
|
|
this.chart_wrapper.addEventListener('mousemove', (e) => {
|
|
let rect = this.chart_wrapper.getBoundingClientRect();
|
|
let offset = {
|
|
top: rect.top + document.body.scrollTop,
|
|
left: rect.left + document.body.scrollLeft
|
|
}
|
|
let relX = e.pageX - offset.left - this.translate_x;
|
|
let relY = e.pageY - offset.top - this.translate_y;
|
|
|
|
if(relY < this.height + this.translate_y * 2) {
|
|
this.map_tooltip_x_position_and_show(relX);
|
|
} else {
|
|
this.tip.hide_tip()
|
|
}
|
|
});
|
|
}
|
|
|
|
map_tooltip_x_position_and_show(relX) {
|
|
for(var i=this.x_axis_values.length - 1; i >= 0 ; i--) {
|
|
let x_val = this.x_axis_values[i];
|
|
// let delta = i === 0 ? this.avg_unit_width : x_val - this.x_axis_values[i-1];
|
|
if(relX > x_val - this.avg_unit_width/2) {
|
|
let x = x_val + this.translate_x - 0.5;
|
|
let y = this.y_min_tops[i] + this.translate_y + 4; // adjustment
|
|
|
|
let title = this.x.formatted && this.x.formatted.length>0
|
|
? this.x.formatted[i] : this.x[i];
|
|
let values = this.y.map((set, j) => {
|
|
return {
|
|
title: set.title,
|
|
value: set.formatted ? set.formatted[i] : set.values[i],
|
|
color: set.color || this.y_colors[j],
|
|
}
|
|
});
|
|
|
|
this.tip.set_values(x, y, title, '', values);
|
|
this.tip.show_tip();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// API
|
|
update_values(new_y) {
|
|
let u = this.unit_args;
|
|
let elements = [];
|
|
elements = this.update_y_axis(elements);
|
|
|
|
this.y.map((d, i) => {
|
|
let new_d = new_y[i];
|
|
new_d.y_tops = new_d.values.map(val => chart_utils.float_2(this.height - val * this.multiplier));
|
|
let new_units = [];
|
|
|
|
// below is equal to this.y[i].svg_units..
|
|
d.svg_units.map((unit, j) => {
|
|
let current_y_top = d.y_tops[j];
|
|
let current_height = this.height - current_y_top;
|
|
|
|
let new_y_top = new_d.y_tops[j];
|
|
let new_height = current_height - (new_y_top - current_y_top);
|
|
|
|
let args = this.animate[u.type]({unit:unit, array:d.svg_units, index: j}, new_y_top, {new_height: new_height});
|
|
|
|
elements.push(args);
|
|
|
|
// Replace values and formatted and tops
|
|
[d.values, d.y_tops] = [new_d.values, new_d.y_tops];
|
|
|
|
});
|
|
|
|
});
|
|
|
|
this.calc_min_tops();
|
|
|
|
// create new x,y pair string and animate path
|
|
if(this.y[0].path) {
|
|
new_y.map((e, i) => {
|
|
let new_points_list = e.y_tops.map((y, i) => (this.x_axis_values[i] + ',' + y));
|
|
let new_path_str = "M"+new_points_list.join("L");
|
|
let args = [{unit:this.y[i].path, object: this.y[i], key:'path'}, {d:new_path_str}, 300, "easein"];
|
|
elements.push(args);
|
|
});
|
|
}
|
|
|
|
let new_svg_units, anim_svg;
|
|
|
|
[new_svg_units, anim_svg] = $$.runSVGAnimation(this.svg, elements);
|
|
|
|
this.chart_wrapper.innerHTML = '';
|
|
this.chart_wrapper.appendChild(anim_svg);
|
|
|
|
setTimeout(() => {
|
|
this.chart_wrapper.innerHTML = '';
|
|
this.chart_wrapper.appendChild(this.svg);
|
|
}, 250);
|
|
}
|
|
|
|
// TODO:
|
|
update_y_axis(elements) {
|
|
// animate up or down
|
|
let old_upper_limit = this.upper_limit;
|
|
// this.setup_y();
|
|
console.log("upper limits", old_upper_limit, this.upper_limit);
|
|
if(this.old_upper_limit !== this.upper_limit){
|
|
//
|
|
}
|
|
return elements;
|
|
}
|
|
|
|
update_x_axis() {
|
|
// update
|
|
}
|
|
|
|
add_data_point(data_point) {
|
|
//
|
|
this.x.push(data_point.label);
|
|
this.y.values.push();
|
|
}
|
|
|
|
// Helpers
|
|
get_upper_limit_and_parts(array) {
|
|
let max_val = parseInt(Math.max(...array));
|
|
if((max_val+"").length <= 1) {
|
|
return [10, 5];
|
|
} else {
|
|
let multiplier = Math.pow(10, ((max_val+"").length - 1));
|
|
let significant = Math.ceil(max_val/multiplier);
|
|
if(significant % 2 !== 0) significant++;
|
|
let parts = (significant < 5) ? significant : significant/2;
|
|
return [significant * multiplier, parts];
|
|
}
|
|
}
|
|
|
|
get_y_axis_values(upper_limit, parts) {
|
|
let y_axis = [];
|
|
for(var i = 0; i <= parts; i++){
|
|
y_axis.push(upper_limit / parts * i);
|
|
}
|
|
return y_axis;
|
|
}
|
|
|
|
set_avg_unit_width_and_x_offset() {
|
|
this.avg_unit_width = this.width/(this.x.length - 1);
|
|
this.x_offset = 0;
|
|
}
|
|
|
|
calc_min_tops() {
|
|
this.y_min_tops = new Array(this.x_axis_values.length).fill(9999);
|
|
this.y.map(d => {
|
|
d.y_tops.map( (y_top, i) => {
|
|
if(y_top < this.y_min_tops[i]) {
|
|
this.y_min_tops[i] = y_top;
|
|
}
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
class BarGraph extends AxisGraph {
|
|
constructor(args) {
|
|
super(arguments[0]);
|
|
|
|
this.type = 'bar-graph';
|
|
this.setup();
|
|
}
|
|
|
|
setup_values() {
|
|
super.setup_values();
|
|
this.x_offset = this.avg_unit_width;
|
|
this.y_axis_mode = 'span';
|
|
this.x_axis_mode = 'tick';
|
|
this.unit_args = {
|
|
type: 'bar',
|
|
args: {
|
|
// More intelligent width setting
|
|
space_width: this.avg_unit_width/2,
|
|
no_of_datasets: this.y.length
|
|
}
|
|
};
|
|
}
|
|
|
|
make_overlay() {
|
|
// Just make one out of the first element
|
|
let unit = this.y[0].svg_units[0];
|
|
|
|
this.overlay = unit.cloneNode();
|
|
this.overlay.style.fill = '#000000';
|
|
this.overlay.style.opacity = '0.4';
|
|
this.chart_group.appendChild(this.overlay);
|
|
}
|
|
|
|
bind_overlay() {
|
|
// on event, update overlay
|
|
this.parent.addEventListener('data-select', (e) => {
|
|
this.update_overlay(e.svg_unit);
|
|
});
|
|
}
|
|
|
|
update_overlay(unit) {
|
|
let attributes = [];
|
|
Object.keys(unit.attributes).map(index => {
|
|
attributes.push(unit.attributes[index]);
|
|
});
|
|
|
|
attributes.filter(attr => attr.specified).map(attr => {
|
|
this.overlay.setAttribute(attr.name, attr.nodeValue);
|
|
});
|
|
}
|
|
|
|
on_left_arrow() {
|
|
this.update_current_data_point(this.current_index - 1);
|
|
}
|
|
|
|
on_right_arrow() {
|
|
this.update_current_data_point(this.current_index + 1);
|
|
}
|
|
|
|
set_avg_unit_width_and_x_offset() {
|
|
this.avg_unit_width = this.width/(this.x.length + 1);
|
|
this.x_offset = this.avg_unit_width;
|
|
}
|
|
}
|
|
|
|
class LineGraph extends AxisGraph {
|
|
constructor(args) {
|
|
super(args);
|
|
if(Object.getPrototypeOf(this) !== LineGraph.prototype) {
|
|
return;
|
|
}
|
|
|
|
this.type = 'line-graph';
|
|
|
|
this.setup();
|
|
}
|
|
|
|
setup_values() {
|
|
super.setup_values();
|
|
this.y_axis_mode = 'tick';
|
|
this.x_axis_mode = 'span';
|
|
this.unit_args = {
|
|
type: 'dot',
|
|
args: { radius: 4 }
|
|
};
|
|
}
|
|
|
|
make_path(d, color) {
|
|
let points_list = d.y_tops.map((y, i) => (this.x_axis_values[i] + ',' + y));
|
|
let path_str = "M"+points_list.join("L");
|
|
|
|
d.path = $$.createSVG('path', {
|
|
className: `stroke ${color}`,
|
|
d: path_str
|
|
});
|
|
|
|
this.svg_units.prepend(d.path);
|
|
}
|
|
}
|
|
|
|
class RegionGraph extends LineGraph {
|
|
constructor(args) {
|
|
super(args);
|
|
|
|
this.type = 'region-graph';
|
|
this.region_fill = 1;
|
|
this.setup();
|
|
}
|
|
}
|
|
|
|
class PercentageGraph extends FrappeChart {
|
|
constructor(args) {
|
|
super(args);
|
|
|
|
this.x = this.data.labels;
|
|
this.y = this.data.datasets;
|
|
|
|
this.get_x_label = this.format_lambdas.x_label;
|
|
this.get_y_label = this.format_lambdas.y_label;
|
|
this.get_x_tooltip = this.format_lambdas.x_tooltip;
|
|
this.get_y_tooltip = this.format_lambdas.y_tooltip;
|
|
|
|
this.setup();
|
|
}
|
|
|
|
make_graph_area() {
|
|
this.chart_wrapper.addClass('graph-focus-margin').attr({
|
|
style: `margin-top: 45px;`
|
|
});
|
|
this.stats_wrapper.addClass('graph-focus-margin').attr({
|
|
style: `padding-top: 0px; margin-bottom: 30px;`
|
|
});
|
|
this.$div = $(`<div class="div" width="${this.base_width}"
|
|
height="${this.base_height}">
|
|
<div class="progress-chart"></div>
|
|
</div>`);
|
|
this.$chart = this.$div.find('.progress-chart');
|
|
return this.$div;
|
|
}
|
|
|
|
setup_values() {
|
|
this.x.totals = this.x.map((d, i) => {
|
|
let total = 0;
|
|
this.y.map(e => {
|
|
total += e.values[i];
|
|
});
|
|
return total;
|
|
});
|
|
|
|
if(!this.x.colors) {
|
|
this.x.colors = ['green', 'blue', 'purple', 'red', 'orange',
|
|
'yellow', 'lightblue', 'lightgreen'];
|
|
}
|
|
}
|
|
|
|
setup_utils() { }
|
|
setup_components() {
|
|
this.$percentage_bar = $(`<div class="progress">
|
|
</div>`).appendTo(this.$chart); // get this.height, width and avg from this if needed
|
|
}
|
|
|
|
make_graph_components() {
|
|
this.grand_total = this.x.totals.reduce((a, b) => a + b, 0);
|
|
this.x.units = [];
|
|
this.x.totals.map((total, i) => {
|
|
let $part = $(`<div class="progress-bar background ${this.x.colors[i]}"
|
|
style="width: ${total*100/this.grand_total}%"></div>`);
|
|
this.x.units.push($part);
|
|
this.$percentage_bar.append($part);
|
|
});
|
|
}
|
|
|
|
bind_tooltip() {
|
|
this.x.units.map(($part, i) => {
|
|
$part.on('mouseenter', () => {
|
|
let g_off = this.chart_wrapper.offset(), p_off = $part.offset();
|
|
|
|
let x = p_off.left - g_off.left + $part.width()/2;
|
|
let y = p_off.top - g_off.top - 6;
|
|
let title = (this.x.formatted && this.x.formatted.length>0
|
|
? this.x.formatted[i] : this.x[i]) + ': ';
|
|
let percent = (this.x.totals[i]*100/this.grand_total).toFixed(1);
|
|
|
|
this.tip.set_values(x, y, title, percent);
|
|
this.tip.show_tip();
|
|
});
|
|
});
|
|
}
|
|
|
|
show_summary() {
|
|
let x_values = this.x.formatted && this.x.formatted.length > 0
|
|
? this.x.formatted : this.x;
|
|
this.x.totals.map((d, i) => {
|
|
if(d) {
|
|
this.stats_wrapper.append($(`<div class="stats">
|
|
<span class="indicator ${this.x.colors[i]}">
|
|
<span class="text-muted">${x_values[i]}:</span>
|
|
${d}
|
|
</span>
|
|
</div>`));
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
class HeatMap extends FrappeChart {
|
|
constructor({
|
|
start = new Date(moment().subtract(1, 'year').toDate()),
|
|
domain = '',
|
|
subdomain = '',
|
|
data = {},
|
|
discrete_domains = 0,
|
|
count_label = ''
|
|
}) {
|
|
super(arguments[0]);
|
|
|
|
this.start = start;
|
|
this.data = data;
|
|
this.discrete_domains = discrete_domains;
|
|
this.count_label = count_label;
|
|
|
|
this.legend_colors = ['#ebedf0', '#c6e48b', '#7bc96f', '#239a3b', '#196127'];
|
|
this.setup();
|
|
}
|
|
|
|
setup_base_values() {
|
|
this.today = new Date();
|
|
|
|
if(!this.start) {
|
|
this.start = new Date();
|
|
this.start.setFullYear( this.start.getFullYear() - 1 );
|
|
}
|
|
this.first_week_start = new Date(this.start.toDateString());
|
|
this.last_week_start = new Date(this.today.toDateString());
|
|
if(this.first_week_start.getDay() !== 7) {
|
|
this.add_days(this.first_week_start, (-1) * this.first_week_start.getDay());
|
|
}
|
|
if(this.last_week_start.getDay() !== 7) {
|
|
this.add_days(this.last_week_start, (-1) * this.last_week_start.getDay());
|
|
}
|
|
this.no_of_cols = this.get_weeks_between(this.first_week_start + '', this.last_week_start + '') + 1;
|
|
}
|
|
|
|
set_width() {
|
|
this.base_width = (this.no_of_cols) * 12;
|
|
}
|
|
|
|
setup_components() {
|
|
this.domain_label_group = this.snap.g().attr({ class: "domain-label-group chart-label" });
|
|
this.data_groups = this.snap.g().attr({ class: "data-groups", transform: `translate(0, 20)` });
|
|
}
|
|
|
|
setup_values() {
|
|
this.distribution = this.get_distribution(this.data, this.legend_colors);
|
|
this.month_names = ["January", "February", "March", "April", "May", "June",
|
|
"July", "August", "September", "October", "November", "December"
|
|
];
|
|
|
|
this.render_all_weeks_and_store_x_values(this.no_of_cols);
|
|
}
|
|
|
|
render_all_weeks_and_store_x_values(no_of_weeks) {
|
|
let current_week_sunday = new Date(this.first_week_start);
|
|
this.week_col = 0;
|
|
this.current_month = current_week_sunday.getMonth();
|
|
|
|
this.months = [this.current_month + ''];
|
|
this.month_weeks = {}, this.month_start_points = [];
|
|
this.month_weeks[this.current_month] = 0;
|
|
this.month_start_points.push(13);
|
|
|
|
for(var i = 0; i < no_of_weeks; i++) {
|
|
let data_group, month_change = 0;
|
|
let day = new Date(current_week_sunday);
|
|
|
|
[data_group, month_change] = this.get_week_squares_group(day, this.week_col);
|
|
this.data_groups.add(data_group);
|
|
this.week_col += 1 + parseInt(this.discrete_domains && month_change);
|
|
this.month_weeks[this.current_month]++;
|
|
if(month_change) {
|
|
this.current_month = (this.current_month + 1) % 12;
|
|
this.months.push(this.current_month + '');
|
|
this.month_weeks[this.current_month] = 1;
|
|
}
|
|
this.add_days(current_week_sunday, 7);
|
|
}
|
|
this.render_month_labels();
|
|
}
|
|
|
|
get_week_squares_group(current_date, index) {
|
|
const no_of_weekdays = 7;
|
|
const square_side = 10;
|
|
const cell_padding = 2;
|
|
const step = 1;
|
|
|
|
let month_change = 0;
|
|
let week_col_change = 0;
|
|
|
|
let data_group = this.snap.g().attr({ class: "data-group" });
|
|
|
|
for(var y = 0, i = 0; i < no_of_weekdays; i += step, y += (square_side + cell_padding)) {
|
|
let data_value = 0;
|
|
let color_index = 0;
|
|
|
|
// TODO: More foolproof for any data
|
|
let timestamp = Math.floor(current_date.getTime()/1000).toFixed(1);
|
|
|
|
if(this.data[timestamp]) {
|
|
data_value = this.data[timestamp];
|
|
color_index = this.get_max_checkpoint(data_value, this.distribution);
|
|
}
|
|
|
|
if(this.data[Math.round(timestamp)]) {
|
|
data_value = this.data[Math.round(timestamp)];
|
|
color_index = this.get_max_checkpoint(data_value, this.distribution);
|
|
}
|
|
|
|
let x = 13 + (index + week_col_change) * 12;
|
|
|
|
data_group.add(this.snap.rect(x, y, square_side, square_side).attr({
|
|
'class': `day`,
|
|
'fill': this.legend_colors[color_index],
|
|
'data-date': this.get_dd_mm_yyyy(current_date),
|
|
'data-value': data_value,
|
|
'data-day': current_date.getDay()
|
|
}));
|
|
|
|
let next_date = new Date(current_date);
|
|
this.add_days(next_date, 1);
|
|
if(next_date.getMonth() - current_date.getMonth()) {
|
|
month_change = 1;
|
|
if(this.discrete_domains) {
|
|
week_col_change = 1;
|
|
}
|
|
|
|
this.month_start_points.push(13 + (index + week_col_change) * 12);
|
|
}
|
|
current_date = next_date;
|
|
}
|
|
|
|
return [data_group, month_change];
|
|
}
|
|
|
|
render_month_labels() {
|
|
this.first_month_label = 1;
|
|
// if (this.first_week_start.getDate() > 8) {
|
|
// this.first_month_label = 0;
|
|
// }
|
|
this.last_month_label = 1;
|
|
|
|
let first_month = this.months.shift();
|
|
let first_month_start = this.month_start_points.shift();
|
|
// render first month if
|
|
|
|
let last_month = this.months.pop();
|
|
let last_month_start = this.month_start_points.pop();
|
|
// render last month if
|
|
|
|
this.month_start_points.map((start, i) => {
|
|
let month_name = this.month_names[this.months[i]].substring(0, 3);
|
|
this.domain_label_group.add(this.snap.text(start + 12, 10, month_name).attr({
|
|
dy: ".32em",
|
|
class: "y-value-text"
|
|
}));
|
|
});
|
|
}
|
|
|
|
make_graph_components() {
|
|
this.container.find('.graph-stats-container, .sub-title, .title').hide();
|
|
this.container.find('.frappe-chart').css({'margin-top': '0px', 'padding-top': '0px'});
|
|
}
|
|
|
|
bind_tooltip() {
|
|
this.container.on('mouseenter', '.day', (e) => {
|
|
let subdomain = $(e.target);
|
|
let count = subdomain.attr('data-value');
|
|
let date_parts = subdomain.attr('data-date').split('-');
|
|
|
|
let month = this.month_names[parseInt(date_parts[1])-1].substring(0, 3);
|
|
|
|
let g_off = this.chart_wrapper.offset(), p_off = subdomain.offset();
|
|
|
|
let width = parseInt(subdomain.attr('width'));
|
|
let x = p_off.left - g_off.left + (width+2)/2;
|
|
let y = p_off.top - g_off.top - (width+2)/2;
|
|
let value = count + ' ' + this.count_label;
|
|
let name = ' on ' + month + ' ' + date_parts[0] + ', ' + date_parts[2];
|
|
|
|
this.tip.set_values(x, y, name, value, [], 1);
|
|
this.tip.show_tip();
|
|
});
|
|
}
|
|
|
|
update(data) {
|
|
this.data = data;
|
|
this.setup_values();
|
|
}
|
|
|
|
get_distribution(data={}, mapper_array) {
|
|
let data_values = Object.keys(data).map(key => data[key]);
|
|
let data_max_value = Math.max(...data_values);
|
|
|
|
let distribution_step = 1 / (mapper_array.length - 1);
|
|
let distribution = [];
|
|
|
|
mapper_array.map((color, i) => {
|
|
let checkpoint = data_max_value * (distribution_step * i);
|
|
distribution.push(checkpoint);
|
|
});
|
|
|
|
return distribution;
|
|
}
|
|
|
|
get_max_checkpoint(value, distribution) {
|
|
return distribution.filter((d, i) => {
|
|
if(i === 1) {
|
|
return distribution[0] < value;
|
|
}
|
|
return d <= value;
|
|
}).length - 1;
|
|
}
|
|
|
|
// TODO: date utils, move these out
|
|
|
|
// https://stackoverflow.com/a/11252167/6495043
|
|
treat_as_utc(date_str) {
|
|
let result = new Date(date_str);
|
|
result.setMinutes(result.getMinutes() - result.getTimezoneOffset());
|
|
return result;
|
|
}
|
|
|
|
get_dd_mm_yyyy(date) {
|
|
let dd = date.getDate();
|
|
let mm = date.getMonth() + 1; // getMonth() is zero-based
|
|
return [
|
|
(dd>9 ? '' : '0') + dd,
|
|
(mm>9 ? '' : '0') + mm,
|
|
date.getFullYear()
|
|
].join('-');
|
|
}
|
|
|
|
get_weeks_between(start_date_str, end_date_str) {
|
|
return Math.ceil(this.get_days_between(start_date_str, end_date_str) / 7);
|
|
}
|
|
|
|
get_days_between(start_date_str, end_date_str) {
|
|
let milliseconds_per_day = 24 * 60 * 60 * 1000;
|
|
return (this.treat_as_utc(end_date_str) - this.treat_as_utc(start_date_str)) / milliseconds_per_day;
|
|
}
|
|
|
|
// mutates
|
|
add_days(date, number_of_days) {
|
|
date.setDate(date.getDate() + number_of_days);
|
|
}
|
|
|
|
get_month_name() {}
|
|
}
|
|
|
|
class SvgTip {
|
|
constructor({
|
|
parent = null
|
|
}) {
|
|
this.parent = parent;
|
|
this.title_name = '';
|
|
this.title_value = '';
|
|
this.list_values = [];
|
|
this.title_value_first = 0;
|
|
|
|
this.x = 0;
|
|
this.y = 0;
|
|
|
|
this.top = 0;
|
|
this.left = 0;
|
|
|
|
this.setup();
|
|
}
|
|
|
|
setup() {
|
|
this.make_tooltip();
|
|
}
|
|
|
|
refresh() {
|
|
this.fill();
|
|
this.calc_position();
|
|
// this.show_tip();
|
|
}
|
|
|
|
make_tooltip() {
|
|
this.container = $$.create('div', {
|
|
className: 'graph-svg-tip comparison',
|
|
innerHTML: `<span class="title"></span>
|
|
<ul class="data-point-list"></ul>
|
|
<div class="svg-pointer"></div>`
|
|
});
|
|
|
|
this.parent.appendChild(this.container);
|
|
this.hide_tip();
|
|
|
|
this.title = this.container.querySelector('.title');
|
|
this.data_point_list = this.container.querySelector('.data-point-list');
|
|
|
|
this.parent.addEventListener('mouseleave', () => {
|
|
this.hide_tip();
|
|
});
|
|
}
|
|
|
|
fill() {
|
|
let title;
|
|
if(this.title_value_first) {
|
|
title = `<strong>${this.title_value}</strong>${this.title_name}`;
|
|
} else {
|
|
title = `${this.title_name}<strong>${this.title_value}</strong>`;
|
|
}
|
|
this.title.innerHTML = title;
|
|
this.data_point_list.innerHTML = '';
|
|
|
|
this.list_values.map((set, i) => {
|
|
let li = $$.create('li', {
|
|
className: `border-top ${set.color || 'black'}`,
|
|
innerHTML: `<strong style="display: block;">${set.value ? set.value : '' }</strong>
|
|
${set.title ? set.title : '' }`
|
|
});
|
|
|
|
this.data_point_list.appendChild(li);
|
|
});
|
|
}
|
|
|
|
calc_position() {
|
|
this.top = this.y - this.container.offsetHeight;
|
|
this.left = this.x - this.container.offsetWidth/2;
|
|
let max_left = this.parent.offsetWidth - this.container.offsetWidth;
|
|
|
|
let pointer = this.container.querySelector('.svg-pointer');
|
|
|
|
if(this.left < 0) {
|
|
pointer.style.left = `calc(50% - ${-1 * this.left}px)`;
|
|
this.left = 0;
|
|
} else if(this.left > max_left) {
|
|
let delta = this.left - max_left;
|
|
pointer.style.left = `calc(50% + ${delta}px)`;
|
|
this.left = max_left;
|
|
} else {
|
|
pointer.style.left = `50%`;
|
|
}
|
|
}
|
|
|
|
set_values(x, y, title_name = '', title_value = '', list_values = [], title_value_first = 0) {
|
|
this.title_name = title_name;
|
|
this.title_value = title_value;
|
|
this.list_values = list_values;
|
|
this.x = x;
|
|
this.y = y;
|
|
this.title_value_first = title_value_first;
|
|
this.refresh();
|
|
}
|
|
|
|
hide_tip() {
|
|
this.container.style.top = '0px';
|
|
this.container.style.left = '0px';
|
|
this.container.style.opacity = '0';
|
|
}
|
|
|
|
show_tip() {
|
|
this.container.style.top = this.top + 'px';
|
|
this.container.style.left = this.left + 'px';
|
|
this.container.style.opacity = '1';
|
|
}
|
|
};
|
|
|
|
function map_c3(chart) {
|
|
if (chart.data) {
|
|
let data = chart.data;
|
|
let type = chart.chart_type || 'line';
|
|
if(type === 'pie') {
|
|
type = 'percentage';
|
|
}
|
|
|
|
let x = {}, y = [];
|
|
|
|
if(data.columns) {
|
|
let columns = data.columns;
|
|
|
|
x = columns.filter(col => {
|
|
return col[0] === data.x;
|
|
})[0];
|
|
|
|
if(x && x.length) {
|
|
let dataset_length = x.length;
|
|
let dirty = false;
|
|
columns.map(col => {
|
|
if(col[0] !== data.x) {
|
|
if(col.length === dataset_length) {
|
|
let title = col[0];
|
|
col.splice(0, 1);
|
|
y.push({
|
|
title: title,
|
|
values: col,
|
|
});
|
|
} else {
|
|
dirty = true;
|
|
}
|
|
}
|
|
})
|
|
|
|
if(dirty) {
|
|
return;
|
|
}
|
|
|
|
x.splice(0, 1);
|
|
|
|
return {
|
|
type: type,
|
|
y: y,
|
|
x: x
|
|
}
|
|
|
|
}
|
|
} else if(data.rows) {
|
|
let rows = data.rows;
|
|
x = rows[0];
|
|
|
|
rows.map((row, i) => {
|
|
if(i === 0) {
|
|
x = row;
|
|
} else {
|
|
y.push({
|
|
title: 'data' + i,
|
|
values: row,
|
|
})
|
|
}
|
|
});
|
|
|
|
return {
|
|
type: type,
|
|
y: y,
|
|
x: x
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Helpers
|
|
chart_utils = {};
|
|
chart_utils.float_2 = d => parseFloat(d.toFixed(2));
|
|
function $$(expr, con) {
|
|
return typeof expr === "string"? (con || document).querySelector(expr) : expr || null;
|
|
}
|
|
|
|
$$.findNodeIndex = (node) =>
|
|
{
|
|
var i = 0;
|
|
while (node = node.previousSibling) {
|
|
if (node.nodeType === 1) { ++i }
|
|
}
|
|
return i;
|
|
}
|
|
|
|
$$.create = function(tag, o) {
|
|
var element = document.createElement(tag);
|
|
|
|
for (var i in o) {
|
|
var val = o[i];
|
|
|
|
if (i === "inside") {
|
|
$$(val).appendChild(element);
|
|
}
|
|
else if (i === "around") {
|
|
var ref = $$(val);
|
|
ref.parentNode.insertBefore(element, ref);
|
|
element.appendChild(ref);
|
|
}
|
|
else if (i in element) {
|
|
element[i] = val;
|
|
}
|
|
else {
|
|
element.setAttribute(i, val);
|
|
}
|
|
}
|
|
|
|
return element;
|
|
};
|
|
|
|
$$.createSVG = function(tag, o) {
|
|
var element = document.createElementNS("http://www.w3.org/2000/svg", tag);
|
|
|
|
for (var i in o) {
|
|
var val = o[i];
|
|
|
|
if (i === "inside") {
|
|
$$(val).appendChild(element);
|
|
}
|
|
else if (i === "around") {
|
|
var ref = $$(val);
|
|
ref.parentNode.insertBefore(element, ref);
|
|
element.appendChild(ref);
|
|
}
|
|
else {
|
|
if(i === "className") { i = "class"}
|
|
if(i === "innerHTML") {
|
|
element['textContent'] = val;
|
|
} else {
|
|
element.setAttribute(i, val);
|
|
}
|
|
}
|
|
}
|
|
|
|
return element;
|
|
};
|
|
|
|
$$.runSVGAnimation = (svg_container, elements) => {
|
|
console.log(elements);
|
|
let parent = elements[0][0]['unit'].parentNode;
|
|
console.log("parent", parent, elements[0][0]);
|
|
let grand_parent = svg_container.parentNode;
|
|
let parent_clone = parent.cloneNode(true);
|
|
let new_parent = parent.cloneNode(true);
|
|
|
|
let new_elements = [];
|
|
let anim_elements = [];
|
|
|
|
elements.map(element => {
|
|
let obj = element[0];
|
|
let index = $$.findNodeIndex(obj.unit);
|
|
|
|
let anim_element, new_element;
|
|
|
|
element[0] = obj.unit;
|
|
[anim_element, new_element] = $$.animateSVG(...element);
|
|
|
|
new_elements.push(new_element);
|
|
anim_elements.push(anim_element);
|
|
|
|
parent.replaceChild(anim_element, obj.unit);
|
|
|
|
if(obj.array) {
|
|
obj.array[obj.index] = new_element;
|
|
} else {
|
|
obj.object[obj.key] = new_element;
|
|
}
|
|
});
|
|
|
|
let anim_svg = svg_container.cloneNode(true);
|
|
|
|
anim_elements.map((anim_element, i) => {
|
|
parent.replaceChild(new_elements[i], anim_element);
|
|
console.log("new el", new_elements[i]);
|
|
elements[i][0] = new_elements[i];
|
|
});
|
|
|
|
return [new_elements, anim_svg];
|
|
}
|
|
|
|
$$.animateSVG = (element, props, dur, easing_type="linear") => {
|
|
let easing = {
|
|
ease: "0.25 0.1 0.25 1",
|
|
linear: "0 0 1 1",
|
|
// easein: "0.42 0 1 1",
|
|
easein: "0.1 0.8 0.2 1",
|
|
easeout: "0 0 0.58 1",
|
|
easeinout: "0.42 0 0.58 1"
|
|
}
|
|
|
|
let anim_element = element.cloneNode(false);
|
|
let new_element = element.cloneNode(false);
|
|
|
|
for(var attributeName in props) {
|
|
let animate_element = document.createElementNS("http://www.w3.org/2000/svg", "animate");
|
|
|
|
let current_value = element.getAttribute(attributeName);
|
|
let value = props[attributeName];
|
|
|
|
let anim_attr = {
|
|
attributeName: attributeName,
|
|
from: current_value,
|
|
to: value,
|
|
begin: "0s",
|
|
dur: dur/1000 + "s",
|
|
values: current_value + ";" + value,
|
|
keySplines: easing[easing_type],
|
|
keyTimes: "0;1",
|
|
calcMode: "spline"
|
|
}
|
|
|
|
for (var i in anim_attr) {
|
|
animate_element.setAttribute(i, anim_attr[i]);
|
|
}
|
|
|
|
anim_element.appendChild(animate_element);
|
|
new_element.setAttribute(attributeName, value);
|
|
}
|
|
|
|
return [anim_element, new_element];
|
|
}
|
|
|
|
$$.bind = function(element, o) {
|
|
if (element) {
|
|
for (var event in o) {
|
|
var callback = o[event];
|
|
|
|
event.split(/\s+/).forEach(function (event) {
|
|
element.addEventListener(event, callback);
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
$$.unbind = function(element, o) {
|
|
if (element) {
|
|
for (var event in o) {
|
|
var callback = o[event];
|
|
|
|
event.split(/\s+/).forEach(function(event) {
|
|
element.removeEventListener(event, callback);
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
$$.fire = function(target, type, properties) {
|
|
var evt = document.createEvent("HTMLEvents");
|
|
|
|
evt.initEvent(type, true, true );
|
|
|
|
for (var j in properties) {
|
|
evt[j] = properties[j];
|
|
}
|
|
|
|
return target.dispatchEvent(evt);
|
|
};
|