You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

1975 lines
49 KiB

  1. import $ from './dom';
  2. import { float_2, arrays_equal } from './utils';
  3. export default class Chart {
  4. constructor({
  5. parent = "",
  6. height = 240,
  7. title = '', subtitle = '',
  8. data = {},
  9. format_lambdas = {},
  10. summary = [],
  11. is_navigable = 0,
  12. type = ''
  13. }) {
  14. if(Object.getPrototypeOf(this) === Chart.prototype) {
  15. if(type === 'line') {
  16. return new LineChart(arguments[0]);
  17. } else if(type === 'bar') {
  18. return new BarChart(arguments[0]);
  19. } else if(type === 'percentage') {
  20. return new PercentageChart(arguments[0]);
  21. } else if(type === 'heatmap') {
  22. return new HeatMap(arguments[0]);
  23. } else {
  24. return new LineChart(arguments[0]);
  25. }
  26. }
  27. this.raw_chart_args = arguments[0];
  28. this.parent = document.querySelector(parent);
  29. this.title = title;
  30. this.subtitle = subtitle;
  31. this.data = data;
  32. this.format_lambdas = format_lambdas;
  33. this.specific_values = data.specific_values || [];
  34. this.summary = summary;
  35. this.is_navigable = is_navigable;
  36. if(this.is_navigable) {
  37. this.current_index = 0;
  38. }
  39. this.chart_types = ['line', 'bar', 'percentage', 'heatmap'];
  40. this.set_margins(height);
  41. }
  42. get_different_chart(type) {
  43. if(!this.chart_types.includes(type)) {
  44. console.error(`'${type}' is not a valid chart type.`);
  45. }
  46. if(type === this.type) return;
  47. // Only across compatible types
  48. let compatible_types = {
  49. bar: ['line', 'percentage'],
  50. line: ['bar', 'percentage'],
  51. percentage: ['bar', 'line'],
  52. heatmap: []
  53. };
  54. if(!compatible_types[this.type].includes(type)) {
  55. console.error(`'${this.type}' chart cannot be converted to a '${type}' chart.`);
  56. }
  57. // Okay, this is anticlimactic
  58. // this function will need to actually be 'change_chart_type(type)'
  59. // that will update only the required elements, but for now ...
  60. return new Chart({
  61. parent: this.raw_chart_args.parent,
  62. data: this.raw_chart_args.data,
  63. type: type,
  64. height: this.raw_chart_args.height
  65. });
  66. }
  67. set_margins(height) {
  68. this.base_height = height;
  69. this.height = height - 40;
  70. this.translate_x = 60;
  71. this.translate_y = 10;
  72. }
  73. setup() {
  74. this.bind_window_events();
  75. this.refresh(true);
  76. }
  77. bind_window_events() {
  78. window.addEventListener('resize', () => this.refresh());
  79. window.addEventListener('orientationchange', () => this.refresh());
  80. }
  81. refresh(init=false) {
  82. this.setup_base_values();
  83. this.set_width();
  84. this.setup_container();
  85. this.setup_components();
  86. this.setup_values();
  87. this.setup_utils();
  88. this.make_graph_components(init);
  89. this.make_tooltip();
  90. if(this.summary.length > 0) {
  91. this.show_custom_summary();
  92. } else {
  93. this.show_summary();
  94. }
  95. if(this.is_navigable) {
  96. this.setup_navigation(init);
  97. }
  98. }
  99. set_width() {
  100. let special_values_width = 0;
  101. this.specific_values.map(val => {
  102. if(this.get_strwidth(val.title) > special_values_width) {
  103. special_values_width = this.get_strwidth(val.title) - 40;
  104. }
  105. });
  106. this.base_width = this.parent.offsetWidth - special_values_width;
  107. this.width = this.base_width - this.translate_x * 2;
  108. }
  109. setup_base_values() {}
  110. setup_container() {
  111. this.container = $.create('div', {
  112. className: 'chart-container',
  113. innerHTML: `<h6 class="title" style="margin-top: 15px;">${this.title}</h6>
  114. <h6 class="sub-title uppercase">${this.subtitle}</h6>
  115. <div class="frappe-chart graphics"></div>
  116. <div class="graph-stats-container"></div>`
  117. });
  118. // Chart needs a dedicated parent element
  119. this.parent.innerHTML = '';
  120. this.parent.appendChild(this.container);
  121. this.chart_wrapper = this.container.querySelector('.frappe-chart');
  122. this.stats_wrapper = this.container.querySelector('.graph-stats-container');
  123. this.make_chart_area();
  124. this.make_draw_area();
  125. }
  126. make_chart_area() {
  127. this.svg = $.createSVG('svg', {
  128. className: 'chart',
  129. inside: this.chart_wrapper,
  130. width: this.base_width,
  131. height: this.base_height
  132. });
  133. this.svg_defs = $.createSVG('defs', {
  134. inside: this.svg,
  135. });
  136. return this.svg;
  137. }
  138. make_draw_area() {
  139. this.draw_area = $.createSVG("g", {
  140. className: this.type + '-chart',
  141. inside: this.svg,
  142. transform: `translate(${this.translate_x}, ${this.translate_y})`
  143. });
  144. }
  145. setup_components() { }
  146. make_tooltip() {
  147. this.tip = new SvgTip({
  148. parent: this.chart_wrapper,
  149. });
  150. this.bind_tooltip();
  151. }
  152. show_summary() {}
  153. show_custom_summary() {
  154. this.summary.map(d => {
  155. let stats = $.create('div', {
  156. className: 'stats',
  157. innerHTML: `<span class="indicator ${d.color}">${d.title}: ${d.value}</span>`
  158. });
  159. this.stats_wrapper.appendChild(stats);
  160. });
  161. }
  162. setup_navigation(init=false) {
  163. this.make_overlay();
  164. if(init) {
  165. this.bind_overlay();
  166. document.addEventListener('keydown', (e) => {
  167. if($.isElementInViewport(this.chart_wrapper)) {
  168. e = e || window.event;
  169. if (e.keyCode == '37') {
  170. this.on_left_arrow();
  171. } else if (e.keyCode == '39') {
  172. this.on_right_arrow();
  173. } else if (e.keyCode == '38') {
  174. this.on_up_arrow();
  175. } else if (e.keyCode == '40') {
  176. this.on_down_arrow();
  177. } else if (e.keyCode == '13') {
  178. this.on_enter_key();
  179. }
  180. }
  181. });
  182. }
  183. }
  184. make_overlay() {}
  185. bind_overlay() {}
  186. on_left_arrow() {}
  187. on_right_arrow() {}
  188. on_up_arrow() {}
  189. on_down_arrow() {}
  190. on_enter_key() {}
  191. get_data_point(index=this.current_index) {
  192. // check for length
  193. let data_point = {
  194. index: index
  195. };
  196. let y = this.y[0];
  197. ['svg_units', 'y_tops', 'values'].map(key => {
  198. let data_key = key.slice(0, key.length-1);
  199. data_point[data_key] = y[key][index];
  200. });
  201. data_point.label = this.x[index];
  202. return data_point;
  203. }
  204. update_current_data_point(index) {
  205. if(index < 0) index = 0;
  206. if(index >= this.x.length) index = this.x.length - 1;
  207. if(index === this.current_index) return;
  208. this.current_index = index;
  209. $.fire(this.parent, "data-select", this.get_data_point());
  210. }
  211. // Helpers
  212. get_strwidth(string) {
  213. return string.length * 8;
  214. }
  215. // Objects
  216. setup_utils() { }
  217. }
  218. class AxisChart extends Chart {
  219. constructor(args) {
  220. super(args);
  221. this.x = this.data.labels;
  222. this.y = this.data.datasets;
  223. this.get_y_label = this.format_lambdas.y_label;
  224. this.get_y_tooltip = this.format_lambdas.y_tooltip;
  225. this.get_x_tooltip = this.format_lambdas.x_tooltip;
  226. this.colors = ['green', 'blue', 'violet', 'red', 'orange',
  227. 'yellow', 'light-blue', 'light-green', 'purple', 'magenta'];
  228. this.zero_line = this.height;
  229. }
  230. setup_values() {
  231. this.data.datasets.map(d => {
  232. d.values = d.values.map(val => (!isNaN(val) ? val : 0));
  233. });
  234. this.setup_x();
  235. this.setup_y();
  236. }
  237. setup_x() {
  238. this.set_avg_unit_width_and_x_offset();
  239. if(this.x_axis_positions) {
  240. this.x_old_axis_positions = this.x_axis_positions.slice();
  241. }
  242. this.x_axis_positions = this.x.map((d, i) =>
  243. float_2(this.x_offset + i * this.avg_unit_width));
  244. if(!this.x_old_axis_positions) {
  245. this.x_old_axis_positions = this.x_axis_positions.slice();
  246. }
  247. }
  248. setup_y() {
  249. if(this.y_axis_values) {
  250. this.y_old_axis_values = this.y_axis_values.slice();
  251. }
  252. let values = this.get_all_y_values();
  253. if(this.y_sums && this.y_sums.length > 0) {
  254. values = values.concat(this.y_sums);
  255. }
  256. this.y_axis_values = this.get_y_axis_points(values);
  257. if(!this.y_old_axis_values) {
  258. this.y_old_axis_values = this.y_axis_values.slice();
  259. }
  260. const y_pts = this.y_axis_values;
  261. const value_range = y_pts[y_pts.length-1] - y_pts[0];
  262. if(this.multiplier) this.old_multiplier = this.multiplier;
  263. this.multiplier = this.height / value_range;
  264. if(!this.old_multiplier) this.old_multiplier = this.multiplier;
  265. const zero_index = y_pts.indexOf(0);
  266. const interval = y_pts[1] - y_pts[0];
  267. const interval_height = interval * this.multiplier;
  268. if(this.zero_line) this.old_zero_line = this.zero_line;
  269. this.zero_line = this.height - (zero_index * interval_height);
  270. if(!this.old_zero_line) this.old_zero_line = this.zero_line;
  271. }
  272. setup_components() {
  273. super.setup_components();
  274. this.setup_marker_components();
  275. this.setup_aggregation_components();
  276. this.setup_graph_components();
  277. }
  278. setup_marker_components() {
  279. this.y_axis_group = $.createSVG('g', {className: 'y axis', inside: this.draw_area});
  280. this.x_axis_group = $.createSVG('g', {className: 'x axis', inside: this.draw_area});
  281. this.specific_y_group = $.createSVG('g', {className: 'specific axis', inside: this.draw_area});
  282. }
  283. setup_aggregation_components() {
  284. this.sum_group = $.createSVG('g', {className: 'data-points', inside: this.draw_area});
  285. this.average_group = $.createSVG('g', {className: 'chart-area', inside: this.draw_area});
  286. }
  287. setup_graph_components() {
  288. this.svg_units_groups = [];
  289. this.y.map((d, i) => {
  290. this.svg_units_groups[i] = $.createSVG('g', {
  291. className: 'data-points data-points-' + i,
  292. inside: this.draw_area
  293. });
  294. });
  295. }
  296. make_graph_components(init=false) {
  297. this.make_y_axis();
  298. this.make_x_axis();
  299. this.draw_graph(init);
  300. this.make_y_specifics();
  301. }
  302. // make VERTICAL lines for x values
  303. make_x_axis(animate=false) {
  304. let start_at, height, text_start_at, axis_line_class = '';
  305. if(this.x_axis_mode === 'span') { // long spanning lines
  306. start_at = -7;
  307. height = this.height + 15;
  308. text_start_at = this.height + 25;
  309. } else if(this.x_axis_mode === 'tick'){ // short label lines
  310. start_at = this.height;
  311. height = 6;
  312. text_start_at = 9;
  313. axis_line_class = 'x-axis-label';
  314. }
  315. this.x_axis_group.setAttribute('transform', `translate(0,${start_at})`);
  316. if(animate) {
  317. this.make_anim_x_axis(height, text_start_at, axis_line_class);
  318. return;
  319. }
  320. this.x_axis_group.textContent = '';
  321. this.x.map((point, i) => {
  322. this.x_axis_group.appendChild(
  323. this.make_x_line(
  324. height,
  325. text_start_at,
  326. point,
  327. 'x-value-text',
  328. axis_line_class,
  329. this.x_axis_positions[i]
  330. )
  331. );
  332. });
  333. }
  334. // make HORIZONTAL lines for y values
  335. make_y_axis(animate=false) {
  336. if(animate) {
  337. this.make_anim_y_axis();
  338. this.make_anim_y_specifics();
  339. return;
  340. }
  341. let [width, text_end_at, axis_line_class, start_at] = this.get_y_axis_line_props();
  342. this.y_axis_group.textContent = '';
  343. this.y_axis_values.map((value, i) => {
  344. this.y_axis_group.appendChild(
  345. this.make_y_line(
  346. start_at,
  347. width,
  348. text_end_at,
  349. value,
  350. 'y-value-text',
  351. axis_line_class,
  352. this.zero_line - value * this.multiplier,
  353. (value === 0 && i !== 0) // Non-first Zero line
  354. )
  355. );
  356. });
  357. }
  358. get_y_axis_line_props(specific=false) {
  359. if(specific) {
  360. return[this.width, this.width + 5, 'specific-value', 0];
  361. }
  362. let width, text_end_at = -9, axis_line_class = '', start_at = 0;
  363. if(this.y_axis_mode === 'span') { // long spanning lines
  364. width = this.width + 6;
  365. start_at = -6;
  366. } else if(this.y_axis_mode === 'tick'){ // short label lines
  367. width = -6;
  368. axis_line_class = 'y-axis-label';
  369. }
  370. return [width, text_end_at, axis_line_class, start_at];
  371. }
  372. draw_graph(init=false) {
  373. if(init) {
  374. this.draw_new_graph_and_animate();
  375. return;
  376. }
  377. this.y.map((d, i) => {
  378. d.svg_units = [];
  379. this.make_path && this.make_path(d, i, this.x_axis_positions, d.y_tops, d.color || this.colors[i]);
  380. this.make_new_units(d, i);
  381. });
  382. }
  383. draw_new_graph_and_animate() {
  384. let data = [];
  385. this.y.map((d, i) => {
  386. // Anim: Don't draw initial values, store them and update later
  387. d.y_tops = new Array(d.values.length).fill(this.zero_line); // no value
  388. data.push({values: d.values});
  389. d.svg_units = [];
  390. this.make_path && this.make_path(d, i, this.x_axis_positions, d.y_tops, d.color || this.colors[i]);
  391. this.make_new_units(d, i);
  392. });
  393. setTimeout(() => {
  394. this.update_values(data);
  395. }, 350);
  396. }
  397. setup_navigation(init) {
  398. // Hack: defer nav till initial update_values
  399. setTimeout(() => {
  400. super.setup_navigation(init);
  401. }, 500);
  402. }
  403. make_new_units(d, i) {
  404. this.make_new_units_for_dataset(
  405. this.x_axis_positions,
  406. d.y_tops,
  407. d.color || this.colors[i],
  408. i,
  409. this.y.length
  410. );
  411. }
  412. make_new_units_for_dataset(x_values, y_values, color, dataset_index, no_of_datasets, group, array, unit) {
  413. if(!group) group = this.svg_units_groups[dataset_index];
  414. if(!array) array = this.y[dataset_index].svg_units;
  415. if(!unit) unit = this.unit_args;
  416. group.textContent = '';
  417. array.length = 0;
  418. y_values.map((y, i) => {
  419. let data_unit = this.draw[unit.type](
  420. x_values[i],
  421. y,
  422. unit.args,
  423. color,
  424. dataset_index,
  425. no_of_datasets
  426. );
  427. group.appendChild(data_unit);
  428. array.push(data_unit);
  429. });
  430. }
  431. make_y_specifics() {
  432. this.specific_y_group.textContent = '';
  433. this.specific_values.map(d => {
  434. this.specific_y_group.appendChild(
  435. this.make_y_line(
  436. 0,
  437. this.width,
  438. this.width + 5,
  439. d.title.toUpperCase(),
  440. 'specific-value',
  441. 'specific-value',
  442. this.zero_line - d.value * this.multiplier,
  443. false,
  444. d.line_type
  445. )
  446. );
  447. });
  448. }
  449. bind_tooltip() {
  450. // TODO: could be in tooltip itself, as it is a given functionality for its parent
  451. this.chart_wrapper.addEventListener('mousemove', (e) => {
  452. let offset = $.offset(this.chart_wrapper);
  453. let relX = e.pageX - offset.left - this.translate_x;
  454. let relY = e.pageY - offset.top - this.translate_y;
  455. if(relY < this.height + this.translate_y * 2) {
  456. this.map_tooltip_x_position_and_show(relX);
  457. } else {
  458. this.tip.hide_tip();
  459. }
  460. });
  461. }
  462. map_tooltip_x_position_and_show(relX) {
  463. for(var i=this.x_axis_positions.length - 1; i >= 0 ; i--) {
  464. let x_val = this.x_axis_positions[i];
  465. // let delta = i === 0 ? this.avg_unit_width : x_val - this.x_axis_positions[i-1];
  466. if(relX > x_val - this.avg_unit_width/2) {
  467. let x = x_val + this.translate_x;
  468. let y = this.y_min_tops[i] + this.translate_y;
  469. let title = this.x.formatted && this.x.formatted.length>0
  470. ? this.x.formatted[i] : this.x[i];
  471. let values = this.y.map((set, j) => {
  472. return {
  473. title: set.title,
  474. value: set.formatted ? set.formatted[i] : set.values[i],
  475. color: set.color || this.colors[j],
  476. };
  477. });
  478. // TODO: upside-down tooltips for negative values?
  479. this.tip.set_values(x, y, title, '', values);
  480. this.tip.show_tip();
  481. break;
  482. }
  483. }
  484. }
  485. // API
  486. show_sums() {
  487. this.updating = true;
  488. this.y_sums = new Array(this.x_axis_positions.length).fill(0);
  489. this.y.map(d => {
  490. d.values.map( (value, i) => {
  491. this.y_sums[i] += value;
  492. });
  493. });
  494. // Remake y axis, animate
  495. this.update_values();
  496. // Then make sum units, don't animate
  497. this.sum_units = [];
  498. this.make_new_units_for_dataset(
  499. this.x_axis_positions,
  500. this.y_sums.map( val => float_2(this.zero_line - val * this.multiplier)),
  501. 'light-grey',
  502. 0,
  503. 1,
  504. this.sum_group,
  505. this.sum_units
  506. );
  507. // this.make_path && this.make_path(d, i, old_x, old_y, d.color || this.colors[i]);
  508. this.updating = false;
  509. }
  510. hide_sums() {
  511. if(this.updating) return;
  512. this.y_sums = [];
  513. this.sum_group.textContent = '';
  514. this.sum_units = [];
  515. this.update_values();
  516. }
  517. show_average() {
  518. this.old_specific_values = this.specific_values.slice();
  519. this.y.map((d, i) => {
  520. let sum = 0;
  521. d.values.map(e => {sum+=e;});
  522. let average = sum/d.values.length;
  523. this.specific_values.push({
  524. title: "AVG" + " " + (i+1),
  525. line_type: "dashed",
  526. value: average,
  527. auto: 1
  528. });
  529. });
  530. this.update_values();
  531. }
  532. hide_average() {
  533. this.old_specific_values = this.specific_values.slice();
  534. let indices_to_remove = [];
  535. this.specific_values.map((d, i) => {
  536. if(d.auto) indices_to_remove.unshift(i);
  537. });
  538. indices_to_remove.map(index => {
  539. this.specific_values.splice(index, 1);
  540. });
  541. this.update_values();
  542. }
  543. update_values(new_y, new_x) {
  544. if(!new_x) {
  545. new_x = this.x;
  546. }
  547. this.elements_to_animate = [];
  548. this.updating = true;
  549. this.old_x_values = this.x.slice();
  550. this.old_y_axis_tops = this.y.map(d => d.y_tops.slice());
  551. this.old_y_values = this.y.map(d => d.values);
  552. this.no_of_extra_pts = new_x.length - this.x.length;
  553. // Just update values prop, setup_x/y() will do the rest
  554. if(new_y) this.y.map((d, i) => {d.values = new_y[i].values;});
  555. if(new_x) this.x = new_x;
  556. this.setup_x();
  557. this.setup_y();
  558. // Animate only if positions have changed
  559. if(!arrays_equal(this.x_old_axis_positions, this.x_axis_positions)) {
  560. this.make_x_axis(true);
  561. setTimeout(() => {
  562. if(!this.updating) this.make_x_axis();
  563. }, 300);
  564. }
  565. if(!arrays_equal(this.y_old_axis_values, this.y_axis_values) ||
  566. (this.old_specific_values &&
  567. !arrays_equal(this.old_specific_values, this.specific_values))) {
  568. this.make_y_axis(true);
  569. setTimeout(() => {
  570. if(!this.updating) {
  571. this.make_y_axis();
  572. this.make_y_specifics();
  573. }
  574. }, 300);
  575. }
  576. // Change in data, so calculate dependencies
  577. this.calc_y_dependencies();
  578. this.animate_graphs();
  579. // Trigger animation with the animatable elements in this.elements_to_animate
  580. this.run_animation();
  581. this.updating = false;
  582. }
  583. add_data_point(y_point, x_point, index=this.x.length) {
  584. let new_y = this.y.map(data_set => { return {values:data_set.values}; });
  585. new_y.map((d, i) => { d.values.splice(index, 0, y_point[i]); });
  586. let new_x = this.x.slice();
  587. new_x.splice(index, 0, x_point);
  588. this.update_values(new_y, new_x);
  589. }
  590. remove_data_point(index = this.x.length-1) {
  591. if(this.x.length < 3) return;
  592. let new_y = this.y.map(data_set => { return {values:data_set.values}; });
  593. new_y.map((d) => { d.values.splice(index, 1); });
  594. let new_x = this.x.slice();
  595. new_x.splice(index, 1);
  596. this.update_values(new_y, new_x);
  597. }
  598. run_animation() {
  599. let anim_svg = $.runSVGAnimation(this.svg, this.elements_to_animate);
  600. if(this.svg.parentNode == this.chart_wrapper) {
  601. this.chart_wrapper.removeChild(this.svg);
  602. this.chart_wrapper.appendChild(anim_svg);
  603. }
  604. // Replace the new svg (data has long been replaced)
  605. setTimeout(() => {
  606. if(anim_svg.parentNode == this.chart_wrapper) {
  607. this.chart_wrapper.removeChild(anim_svg);
  608. this.chart_wrapper.appendChild(this.svg);
  609. }
  610. }, 200);
  611. }
  612. animate_graphs() {
  613. this.y.map((d, i) => {
  614. // Pre-prep, equilize no of positions between old and new
  615. let [old_x, old_y, new_x, new_y] = this.calc_old_and_new_postions(d, i);
  616. if(this.no_of_extra_pts >= 0) {
  617. this.make_path && this.make_path(d, i, old_x, old_y, d.color || this.colors[i]);
  618. this.make_new_units_for_dataset(old_x, old_y, d.color || this.colors[i], i, this.y.length);
  619. }
  620. d.path && this.animate_path(d, i, old_x, old_y, new_x, new_y);
  621. this.animate_units(d, i, old_x, old_y, new_x, new_y);
  622. });
  623. // TODO: replace with real units
  624. setTimeout(() => {
  625. this.y.map((d, i) => {
  626. this.make_path && this.make_path(d, i, this.x_axis_positions, d.y_tops, d.color || this.colors[i]);
  627. this.make_new_units(d, i);
  628. });
  629. }, 300);
  630. }
  631. animate_path(d, i, old_x, old_y, new_x, new_y) {
  632. // Animate path
  633. const new_points_list = new_y.map((y, i) => (new_x[i] + ',' + y));
  634. const new_path_str = new_points_list.join("L");
  635. const path_args = [{unit: d.path, object: d, key: 'path'}, {d:"M"+new_path_str}, 250, "easein"];
  636. this.elements_to_animate.push(path_args);
  637. // Animate region
  638. if(d.region_path) {
  639. let reg_start_pt = `0,${this.zero_line}L`;
  640. let reg_end_pt = `L${this.width},${this.zero_line}`;
  641. const region_args = [
  642. {unit: d.region_path, object: d, key: 'region_path'},
  643. {d:"M" + reg_start_pt + new_path_str + reg_end_pt},
  644. 250,
  645. "easein"
  646. ];
  647. this.elements_to_animate.push(region_args);
  648. }
  649. }
  650. animate_units(d, index, old_x, old_y, new_x, new_y) {
  651. let type = this.unit_args.type;
  652. d.svg_units.map((unit, i) => {
  653. this.elements_to_animate.push(this.animate[type](
  654. {unit:unit, array:d.svg_units, index: i}, // unit, with info to replace where it came from in the data
  655. new_x[i],
  656. new_y[i],
  657. index
  658. ));
  659. });
  660. }
  661. calc_old_and_new_postions(d, i) {
  662. let old_x = this.x_old_axis_positions.slice();
  663. let new_x = this.x_axis_positions.slice();
  664. let old_y = this.old_y_axis_tops[i].slice();
  665. let new_y = d.y_tops.slice();
  666. const last_old_x_pos = old_x[old_x.length - 1];
  667. const last_old_y_pos = old_y[old_y.length - 1];
  668. const last_new_x_pos = new_x[new_x.length - 1];
  669. const last_new_y_pos = new_y[new_y.length - 1];
  670. if(this.no_of_extra_pts >= 0) {
  671. // First substitute current path with a squiggled one (looking the same but
  672. // having more points at end),
  673. // then animate to stretch it later to new points
  674. // (new points already have more points)
  675. // Hence, the extra end points will correspond to current(old) positions
  676. let filler_x = new Array(Math.abs(this.no_of_extra_pts)).fill(last_old_x_pos);
  677. let filler_y = new Array(Math.abs(this.no_of_extra_pts)).fill(last_old_y_pos);
  678. old_x = old_x.concat(filler_x);
  679. old_y = old_y.concat(filler_y);
  680. } else {
  681. // Just modify the new points to have extra points
  682. // with the same position at end
  683. let filler_x = new Array(Math.abs(this.no_of_extra_pts)).fill(last_new_x_pos);
  684. let filler_y = new Array(Math.abs(this.no_of_extra_pts)).fill(last_new_y_pos);
  685. new_x = new_x.concat(filler_x);
  686. new_y = new_y.concat(filler_y);
  687. }
  688. return [old_x, old_y, new_x, new_y];
  689. }
  690. make_anim_x_axis(height, text_start_at, axis_line_class) {
  691. // Animate X AXIS to account for more or less axis lines
  692. const old_pos = this.x_old_axis_positions;
  693. const new_pos = this.x_axis_positions;
  694. const old_vals = this.old_x_values;
  695. const new_vals = this.x;
  696. const last_line_pos = old_pos[old_pos.length - 1];
  697. let add_and_animate_line = (value, old_pos, new_pos) => {
  698. const x_line = this.make_x_line(
  699. height,
  700. text_start_at,
  701. value, // new value
  702. 'x-value-text',
  703. axis_line_class,
  704. old_pos // old position
  705. );
  706. this.x_axis_group.appendChild(x_line);
  707. this.elements_to_animate && this.elements_to_animate.push([
  708. {unit: x_line, array: [0], index: 0},
  709. {transform: `${ new_pos }, 0`},
  710. 250,
  711. "easein",
  712. "translate",
  713. {transform: `${ old_pos }, 0`}
  714. ]);
  715. };
  716. this.x_axis_group.textContent = '';
  717. this.make_new_axis_anim_lines(
  718. old_pos,
  719. new_pos,
  720. old_vals,
  721. new_vals,
  722. last_line_pos,
  723. add_and_animate_line
  724. );
  725. }
  726. make_anim_y_axis() {
  727. // Animate Y AXIS to account for more or less axis lines
  728. const old_pos = this.y_old_axis_values.map(value =>
  729. this.zero_line - value * this.multiplier);
  730. const new_pos = this.y_axis_values.map(value =>
  731. this.zero_line - value * this.multiplier);
  732. const old_vals = this.y_old_axis_values;
  733. const new_vals = this.y_axis_values;
  734. const last_line_pos = old_pos[old_pos.length - 1];
  735. this.y_axis_group.textContent = '';
  736. this.make_new_axis_anim_lines(
  737. old_pos,
  738. new_pos,
  739. old_vals,
  740. new_vals,
  741. last_line_pos,
  742. this.add_and_animate_y_line.bind(this),
  743. this.y_axis_group
  744. );
  745. }
  746. make_anim_y_specifics() {
  747. this.specific_y_group.textContent = '';
  748. this.specific_values.map((d) => {
  749. this.add_and_animate_y_line(
  750. d.title,
  751. this.old_zero_line - d.value * this.old_multiplier,
  752. this.zero_line - d.value * this.multiplier,
  753. 0,
  754. this.specific_y_group,
  755. d.line_type,
  756. true
  757. );
  758. });
  759. }
  760. make_new_axis_anim_lines(old_pos, new_pos, old_vals, new_vals, last_line_pos, add_and_animate_line, group) {
  761. let superimposed_positions, superimposed_values;
  762. let no_of_extras = new_vals.length - old_vals.length;
  763. if(no_of_extras > 0) {
  764. // More axis are needed
  765. // First make only the superimposed (same position) ones
  766. // Add in the extras at the end later
  767. superimposed_positions = new_pos.slice(0, old_pos.length);
  768. superimposed_values = new_vals.slice(0, old_vals.length);
  769. } else {
  770. // Axis have to be reduced
  771. // Fake it by moving all current extra axis to the last position
  772. // You'll need filler positions and values in the new arrays
  773. const filler_vals = new Array(Math.abs(no_of_extras)).fill("");
  774. superimposed_values = new_vals.concat(filler_vals);
  775. const filler_pos = new Array(Math.abs(no_of_extras)).fill(last_line_pos);
  776. superimposed_positions = new_pos.concat(filler_pos);
  777. }
  778. superimposed_values.map((value, i) => {
  779. add_and_animate_line(value, old_pos[i], superimposed_positions[i], i, group);
  780. });
  781. if(no_of_extras > 0) {
  782. // Add in extra axis in the end
  783. // and then animate to new positions
  784. const extra_values = new_vals.slice(old_vals.length);
  785. const extra_positions = new_pos.slice(old_pos.length);
  786. extra_values.map((value, i) => {
  787. add_and_animate_line(value, last_line_pos, extra_positions[i], i, group);
  788. });
  789. }
  790. }
  791. make_x_line(height, text_start_at, point, label_class, axis_line_class, x_pos) {
  792. let allowed_space = this.avg_unit_width * 1.5;
  793. if(this.get_strwidth(point) > allowed_space) {
  794. let allowed_letters = allowed_space / 8;
  795. point = point.slice(0, allowed_letters-3) + " ...";
  796. }
  797. let line = $.createSVG('line', {
  798. x1: 0,
  799. x2: 0,
  800. y1: 0,
  801. y2: height
  802. });
  803. let text = $.createSVG('text', {
  804. className: label_class,
  805. x: 0,
  806. y: text_start_at,
  807. dy: '.71em',
  808. innerHTML: point
  809. });
  810. let x_level = $.createSVG('g', {
  811. className: `tick ${axis_line_class}`,
  812. transform: `translate(${ x_pos }, 0)`
  813. });
  814. x_level.appendChild(line);
  815. x_level.appendChild(text);
  816. return x_level;
  817. }
  818. make_y_line(start_at, width, text_end_at, point, label_class, axis_line_class, y_pos, darker=false, line_type="") {
  819. let line = $.createSVG('line', {
  820. className: line_type === "dashed" ? "dashed": "",
  821. x1: start_at,
  822. x2: width,
  823. y1: 0,
  824. y2: 0
  825. });
  826. let text = $.createSVG('text', {
  827. className: label_class,
  828. x: text_end_at,
  829. y: 0,
  830. dy: '.32em',
  831. innerHTML: point+""
  832. });
  833. let y_level = $.createSVG('g', {
  834. className: `tick ${axis_line_class}`,
  835. transform: `translate(0, ${y_pos})`
  836. });
  837. if(darker) {
  838. line.style.stroke = "rgba(27, 31, 35, 0.6)";
  839. }
  840. y_level.appendChild(line);
  841. y_level.appendChild(text);
  842. return y_level;
  843. }
  844. add_and_animate_y_line(value, old_pos, new_pos, i, group, type, specific=false) {
  845. let [width, text_end_at, axis_line_class, start_at] = this.get_y_axis_line_props(specific);
  846. let axis_label_class = !specific ? 'y-value-text' : 'specific-value';
  847. value = !specific ? value : (value+"").toUpperCase();
  848. const y_line = this.make_y_line(
  849. start_at,
  850. width,
  851. text_end_at,
  852. value,
  853. axis_label_class,
  854. axis_line_class,
  855. old_pos, // old position
  856. (value === 0 && i !== 0), // Non-first Zero line
  857. type
  858. );
  859. group.appendChild(y_line);
  860. this.elements_to_animate && this.elements_to_animate.push([
  861. {unit: y_line, array: [0], index: 0},
  862. {transform: `0, ${ new_pos }`},
  863. 250,
  864. "easein",
  865. "translate",
  866. {transform: `0, ${ old_pos }`}
  867. ]);
  868. }
  869. get_y_axis_points(array) {
  870. //*** Where the magic happens ***
  871. // Calculates best-fit y intervals from given values
  872. // and returns the interval array
  873. // TODO: Fractions
  874. let max_bound, min_bound, pos_no_of_parts, neg_no_of_parts, part_size; // eslint-disable-line no-unused-vars
  875. // Critical values
  876. let max_val = parseInt(Math.max(...array));
  877. let min_val = parseInt(Math.min(...array));
  878. if(min_val >= 0) {
  879. min_val = 0;
  880. }
  881. let get_params = (value1, value2) => {
  882. let bound1, bound2, no_of_parts_1, no_of_parts_2, interval_size;
  883. if((value1+"").length <= 1) {
  884. [bound1, no_of_parts_1] = [10, 5];
  885. } else {
  886. [bound1, no_of_parts_1] = this.calc_upper_bound_and_no_of_parts(value1);
  887. }
  888. interval_size = bound1 / no_of_parts_1;
  889. no_of_parts_2 = this.calc_no_of_parts(value2, interval_size);
  890. bound2 = no_of_parts_2 * interval_size;
  891. return [bound1, bound2, no_of_parts_1, no_of_parts_2, interval_size];
  892. };
  893. const abs_min_val = min_val * -1;
  894. if(abs_min_val <= max_val) {
  895. // Get the positive region intervals
  896. // then calc negative ones accordingly
  897. [max_bound, min_bound, pos_no_of_parts, neg_no_of_parts, part_size]
  898. = get_params(max_val, abs_min_val);
  899. if(abs_min_val === 0) {
  900. min_bound = 0; neg_no_of_parts = 0;
  901. }
  902. } else {
  903. // Get the negative region here first
  904. [min_bound, max_bound, neg_no_of_parts, pos_no_of_parts, part_size]
  905. = get_params(abs_min_val, max_val);
  906. }
  907. // Make both region parts even
  908. if(pos_no_of_parts % 2 !== 0 && neg_no_of_parts > 0) pos_no_of_parts++;
  909. if(neg_no_of_parts % 2 !== 0) {
  910. // every increase in no_of_parts entails an increase in corresponding bound
  911. // except here, it happens implicitly after every calc_no_of_parts() call
  912. neg_no_of_parts++;
  913. min_bound += part_size;
  914. }
  915. let no_of_parts = pos_no_of_parts + neg_no_of_parts;
  916. if(no_of_parts > 5) {
  917. no_of_parts /= 2;
  918. part_size *= 2;
  919. }
  920. return this.get_intervals(
  921. (-1) * min_bound,
  922. part_size,
  923. no_of_parts
  924. );
  925. }
  926. get_intervals(start, interval_size, count) {
  927. let intervals = [];
  928. for(var i = 0; i <= count; i++){
  929. intervals.push(start);
  930. start += interval_size;
  931. }
  932. return intervals;
  933. }
  934. calc_upper_bound_and_no_of_parts(max_val) {
  935. // Given a positive value, calculates a nice-number upper bound
  936. // and a consequent optimal number of parts
  937. const part_size = Math.pow(10, ((max_val+"").length - 1));
  938. const no_of_parts = this.calc_no_of_parts(max_val, part_size);
  939. // Use it to get a nice even upper bound
  940. const upper_bound = part_size * no_of_parts;
  941. return [upper_bound, no_of_parts];
  942. }
  943. calc_no_of_parts(value, divisor) {
  944. // value should be a positive number, divisor should be greater than 0
  945. // returns an even no of parts
  946. let no_of_parts = Math.ceil(value / divisor);
  947. if(no_of_parts % 2 !== 0) no_of_parts++; // Make it an even number
  948. return no_of_parts;
  949. }
  950. get_optimal_no_of_parts(no_of_parts) {
  951. // aka Divide by 2 if too large
  952. return (no_of_parts < 5) ? no_of_parts : no_of_parts / 2;
  953. }
  954. set_avg_unit_width_and_x_offset() {
  955. // Set the ... you get it
  956. this.avg_unit_width = this.width/(this.x.length - 1);
  957. this.x_offset = 0;
  958. }
  959. get_all_y_values() {
  960. let all_values = [];
  961. // Add in all the y values in the datasets
  962. this.y.map(d => {
  963. all_values = all_values.concat(d.values);
  964. });
  965. // Add in all the specific values
  966. return all_values.concat(this.specific_values.map(d => d.value));
  967. }
  968. calc_y_dependencies() {
  969. this.y_min_tops = new Array(this.x_axis_positions.length).fill(9999);
  970. this.y.map(d => {
  971. d.y_tops = d.values.map( val => float_2(this.zero_line - val * this.multiplier));
  972. d.y_tops.map( (y_top, i) => {
  973. if(y_top < this.y_min_tops[i]) {
  974. this.y_min_tops[i] = y_top;
  975. }
  976. });
  977. });
  978. }
  979. get_bar_height_and_y_attr(y_top) {
  980. let height, y;
  981. if (y_top <= this.zero_line) {
  982. height = this.zero_line - y_top;
  983. y = y_top;
  984. // In case of invisible bars
  985. if(height === 0) {
  986. height = this.height * 0.01;
  987. y -= height;
  988. }
  989. } else {
  990. height = y_top - this.zero_line;
  991. y = this.zero_line;
  992. // In case of invisible bars
  993. if(height === 0) {
  994. height = this.height * 0.01;
  995. }
  996. }
  997. return [height, y];
  998. }
  999. setup_utils() {
  1000. this.draw = {
  1001. 'bar': (x, y_top, args, color, index, no_of_datasets) => {
  1002. let total_width = this.avg_unit_width - args.space_width;
  1003. let start_x = x - total_width/2;
  1004. let width = total_width / no_of_datasets;
  1005. let current_x = start_x + width * index;
  1006. let [height, y] = this.get_bar_height_and_y_attr(y_top);
  1007. return $.createSVG('rect', {
  1008. className: `bar mini fill ${color}`,
  1009. x: current_x,
  1010. y: y,
  1011. width: width,
  1012. height: height
  1013. });
  1014. },
  1015. 'dot': (x, y, args, color) => {
  1016. return $.createSVG('circle', {
  1017. className: `fill ${color}`,
  1018. cx: x,
  1019. cy: y,
  1020. r: args.radius
  1021. });
  1022. }
  1023. };
  1024. this.animate = {
  1025. 'bar': (bar_obj, x, y_top, index) => {
  1026. let start = x - this.avg_unit_width/4;
  1027. let width = (this.avg_unit_width/2)/this.y.length;
  1028. let [height, y] = this.get_bar_height_and_y_attr(y_top);
  1029. x = start + (width * index);
  1030. return [bar_obj, {width: width, height: height, x: x, y: y}, 250, "easein"];
  1031. // bar.animate({height: args.new_height, y: y_top}, 250, mina.easein);
  1032. },
  1033. 'dot': (dot_obj, x, y_top) => {
  1034. return [dot_obj, {cx: x, cy: y_top}, 300, "easein"];
  1035. // dot.animate({cy: y_top}, 250, mina.easein);
  1036. }
  1037. };
  1038. }
  1039. }
  1040. class BarChart extends AxisChart {
  1041. constructor(args) {
  1042. super(args);
  1043. this.type = 'bar';
  1044. this.x_axis_mode = args.x_axis_mode || 'tick';
  1045. this.y_axis_mode = args.y_axis_mode || 'span';
  1046. this.setup();
  1047. }
  1048. setup_values() {
  1049. super.setup_values();
  1050. this.x_offset = this.avg_unit_width;
  1051. this.unit_args = {
  1052. type: 'bar',
  1053. args: {
  1054. space_width: this.avg_unit_width/2,
  1055. }
  1056. };
  1057. }
  1058. make_overlay() {
  1059. // Just make one out of the first element
  1060. let index = this.x.length - 1;
  1061. let unit = this.y[0].svg_units[index];
  1062. this.update_current_data_point(index);
  1063. if(this.overlay) {
  1064. this.overlay.parentNode.removeChild(this.overlay);
  1065. }
  1066. this.overlay = unit.cloneNode();
  1067. this.overlay.style.fill = '#000000';
  1068. this.overlay.style.opacity = '0.4';
  1069. this.draw_area.appendChild(this.overlay);
  1070. }
  1071. bind_overlay() {
  1072. // on event, update overlay
  1073. this.parent.addEventListener('data-select', (e) => {
  1074. this.update_overlay(e.svg_unit);
  1075. });
  1076. }
  1077. update_overlay(unit) {
  1078. let attributes = [];
  1079. Object.keys(unit.attributes).map(index => {
  1080. attributes.push(unit.attributes[index]);
  1081. });
  1082. attributes.filter(attr => attr.specified).map(attr => {
  1083. this.overlay.setAttribute(attr.name, attr.nodeValue);
  1084. });
  1085. }
  1086. on_left_arrow() {
  1087. this.update_current_data_point(this.current_index - 1);
  1088. }
  1089. on_right_arrow() {
  1090. this.update_current_data_point(this.current_index + 1);
  1091. }
  1092. set_avg_unit_width_and_x_offset() {
  1093. this.avg_unit_width = this.width/(this.x.length + 1);
  1094. this.x_offset = this.avg_unit_width;
  1095. }
  1096. }
  1097. class LineChart extends AxisChart {
  1098. constructor(args) {
  1099. super(args);
  1100. if(Object.getPrototypeOf(this) !== LineChart.prototype) {
  1101. return;
  1102. }
  1103. this.type = 'line';
  1104. this.region_fill = args.region_fill;
  1105. this.x_axis_mode = args.x_axis_mode || 'span';
  1106. this.y_axis_mode = args.y_axis_mode || 'span';
  1107. this.setup();
  1108. }
  1109. setup_graph_components() {
  1110. this.setup_path_groups();
  1111. super.setup_graph_components();
  1112. }
  1113. setup_path_groups() {
  1114. this.paths_groups = [];
  1115. this.y.map((d, i) => {
  1116. this.paths_groups[i] = $.createSVG('g', {
  1117. className: 'path-group path-group-' + i,
  1118. inside: this.draw_area
  1119. });
  1120. });
  1121. }
  1122. setup_values() {
  1123. super.setup_values();
  1124. this.unit_args = {
  1125. type: 'dot',
  1126. args: { radius: 8 }
  1127. };
  1128. }
  1129. make_paths() {
  1130. this.y.map((d, i) => {
  1131. this.make_path(d, i, this.x_axis_positions, d.y_tops, d.color || this.colors[i]);
  1132. });
  1133. }
  1134. make_path(d, i, x_positions, y_positions, color) {
  1135. let points_list = y_positions.map((y, i) => (x_positions[i] + ',' + y));
  1136. let points_str = points_list.join("L");
  1137. this.paths_groups[i].textContent = '';
  1138. d.path = $.createSVG('path', {
  1139. inside: this.paths_groups[i],
  1140. className: `stroke ${color}`,
  1141. d: "M"+points_str
  1142. });
  1143. if(this.region_fill) {
  1144. let gradient_id ='path-fill-gradient' + '-' + color;
  1145. this.gradient_def = $.createSVG('linearGradient', {
  1146. inside: this.svg_defs,
  1147. id: gradient_id,
  1148. x1: 0,
  1149. x2: 0,
  1150. y1: 0,
  1151. y2: 1
  1152. });
  1153. let set_gradient_stop = (grad_elem, offset, color, opacity) => {
  1154. $.createSVG('stop', {
  1155. 'className': 'stop-color ' + color,
  1156. 'inside': grad_elem,
  1157. 'offset': offset,
  1158. 'stop-opacity': opacity
  1159. });
  1160. };
  1161. set_gradient_stop(this.gradient_def, "0%", color, 0.4);
  1162. set_gradient_stop(this.gradient_def, "50%", color, 0.2);
  1163. set_gradient_stop(this.gradient_def, "100%", color, 0);
  1164. d.region_path = $.createSVG('path', {
  1165. inside: this.paths_groups[i],
  1166. className: `region-fill`,
  1167. d: "M" + `0,${this.zero_line}L` + points_str + `L${this.width},${this.zero_line}`,
  1168. });
  1169. d.region_path.style.stroke = "none";
  1170. d.region_path.style.fill = `url(#${gradient_id})`;
  1171. }
  1172. }
  1173. }
  1174. class PercentageChart extends Chart {
  1175. constructor(args) {
  1176. super(args);
  1177. this.type = 'percentage';
  1178. this.get_y_label = this.format_lambdas.y_label;
  1179. this.get_x_tooltip = this.format_lambdas.x_tooltip;
  1180. this.get_y_tooltip = this.format_lambdas.y_tooltip;
  1181. this.max_slices = 10;
  1182. this.max_legend_points = 6;
  1183. this.colors = args.colors;
  1184. if(!this.colors || this.colors.length < this.data.labels.length) {
  1185. this.colors = ['light-blue', 'blue', 'violet', 'red', 'orange',
  1186. 'yellow', 'green', 'light-green', 'purple', 'magenta'];
  1187. }
  1188. this.setup();
  1189. }
  1190. make_chart_area() {
  1191. this.chart_wrapper.className += ' ' + 'graph-focus-margin';
  1192. this.chart_wrapper.style.marginTop = '45px';
  1193. this.stats_wrapper.className += ' ' + 'graph-focus-margin';
  1194. this.stats_wrapper.style.marginBottom = '30px';
  1195. this.stats_wrapper.style.paddingTop = '0px';
  1196. }
  1197. make_draw_area() {
  1198. this.chart_div = $.create('div', {
  1199. className: 'div',
  1200. inside: this.chart_wrapper,
  1201. width: this.base_width,
  1202. height: this.base_height
  1203. });
  1204. this.chart = $.create('div', {
  1205. className: 'progress-chart',
  1206. inside: this.chart_div
  1207. });
  1208. }
  1209. setup_components() {
  1210. this.percentage_bar = $.create('div', {
  1211. className: 'progress',
  1212. inside: this.chart
  1213. });
  1214. }
  1215. setup_values() {
  1216. this.slice_totals = [];
  1217. let all_totals = this.data.labels.map((d, i) => {
  1218. let total = 0;
  1219. this.data.datasets.map(e => {
  1220. total += e.values[i];
  1221. });
  1222. return [total, d];
  1223. }).filter(d => { return d[0] > 0; }); // keep only positive results
  1224. let totals = all_totals;
  1225. if(all_totals.length > this.max_slices) {
  1226. all_totals.sort((a, b) => { return b[0] - a[0]; });
  1227. totals = all_totals.slice(0, this.max_slices-1);
  1228. let others = all_totals.slice(this.max_slices-1);
  1229. let sum_of_others = 0;
  1230. others.map(d => {sum_of_others += d[0];});
  1231. totals.push([sum_of_others, 'Rest']);
  1232. this.colors[this.max_slices-1] = 'grey';
  1233. }
  1234. this.labels = [];
  1235. totals.map(d => {
  1236. this.slice_totals.push(d[0]);
  1237. this.labels.push(d[1]);
  1238. });
  1239. this.legend_totals = this.slice_totals.slice(0, this.max_legend_points);
  1240. }
  1241. setup_utils() { }
  1242. make_graph_components() {
  1243. this.grand_total = this.slice_totals.reduce((a, b) => a + b, 0);
  1244. this.slices = [];
  1245. this.slice_totals.map((total, i) => {
  1246. let slice = $.create('div', {
  1247. className: `progress-bar background ${this.colors[i]}`,
  1248. style: `width: ${total*100/this.grand_total}%`,
  1249. inside: this.percentage_bar
  1250. });
  1251. this.slices.push(slice);
  1252. });
  1253. }
  1254. bind_tooltip() {
  1255. this.slices.map((slice, i) => {
  1256. slice.addEventListener('mouseenter', () => {
  1257. let g_off = $.offset(this.chart_wrapper), p_off = $.offset(slice);
  1258. let x = p_off.left - g_off.left + slice.offsetWidth/2;
  1259. let y = p_off.top - g_off.top - 6;
  1260. let title = (this.formatted_labels && this.formatted_labels.length>0
  1261. ? this.formatted_labels[i] : this.labels[i]) + ': ';
  1262. let percent = (this.slice_totals[i]*100/this.grand_total).toFixed(1);
  1263. this.tip.set_values(x, y, title, percent + "%");
  1264. this.tip.show_tip();
  1265. });
  1266. });
  1267. }
  1268. show_summary() {
  1269. let x_values = this.formatted_labels && this.formatted_labels.length > 0
  1270. ? this.formatted_labels : this.labels;
  1271. this.legend_totals.map((d, i) => {
  1272. if(d) {
  1273. let stats = $.create('div', {
  1274. className: 'stats',
  1275. inside: this.stats_wrapper
  1276. });
  1277. stats.innerHTML = `<span class="indicator ${this.colors[i]}">
  1278. <span class="text-muted">${x_values[i]}:</span>
  1279. ${d}
  1280. </span>`;
  1281. }
  1282. });
  1283. }
  1284. }
  1285. class HeatMap extends Chart {
  1286. constructor({
  1287. start = '',
  1288. domain = '',
  1289. subdomain = '',
  1290. data = {},
  1291. discrete_domains = 0,
  1292. count_label = ''
  1293. }) {
  1294. super(arguments[0]);
  1295. this.type = 'heatmap';
  1296. this.domain = domain;
  1297. this.subdomain = subdomain;
  1298. this.data = data;
  1299. this.discrete_domains = discrete_domains;
  1300. this.count_label = count_label;
  1301. let today = new Date();
  1302. this.start = start || this.add_days(today, 365);
  1303. this.legend_colors = ['#ebedf0', '#c6e48b', '#7bc96f', '#239a3b', '#196127'];
  1304. this.translate_x = 0;
  1305. this.setup();
  1306. }
  1307. setup_base_values() {
  1308. this.today = new Date();
  1309. if(!this.start) {
  1310. this.start = new Date();
  1311. this.start.setFullYear( this.start.getFullYear() - 1 );
  1312. }
  1313. this.first_week_start = new Date(this.start.toDateString());
  1314. this.last_week_start = new Date(this.today.toDateString());
  1315. if(this.first_week_start.getDay() !== 7) {
  1316. this.add_days(this.first_week_start, (-1) * this.first_week_start.getDay());
  1317. }
  1318. if(this.last_week_start.getDay() !== 7) {
  1319. this.add_days(this.last_week_start, (-1) * this.last_week_start.getDay());
  1320. }
  1321. this.no_of_cols = this.get_weeks_between(this.first_week_start + '', this.last_week_start + '') + 1;
  1322. }
  1323. set_width() {
  1324. this.base_width = (this.no_of_cols) * 12;
  1325. if(this.discrete_domains) {
  1326. this.base_width += (12 * 12);
  1327. }
  1328. }
  1329. setup_components() {
  1330. this.domain_label_group = $.createSVG("g", {
  1331. className: "domain-label-group chart-label",
  1332. inside: this.draw_area
  1333. });
  1334. this.data_groups = $.createSVG("g", {
  1335. className: "data-groups",
  1336. inside: this.draw_area,
  1337. transform: `translate(0, 20)`
  1338. });
  1339. }
  1340. setup_values() {
  1341. this.domain_label_group.textContent = '';
  1342. this.data_groups.textContent = '';
  1343. this.distribution = this.get_distribution(this.data, this.legend_colors);
  1344. this.month_names = ["January", "February", "March", "April", "May", "June",
  1345. "July", "August", "September", "October", "November", "December"
  1346. ];
  1347. this.render_all_weeks_and_store_x_values(this.no_of_cols);
  1348. }
  1349. render_all_weeks_and_store_x_values(no_of_weeks) {
  1350. let current_week_sunday = new Date(this.first_week_start);
  1351. this.week_col = 0;
  1352. this.current_month = current_week_sunday.getMonth();
  1353. this.months = [this.current_month + ''];
  1354. this.month_weeks = {}, this.month_start_points = [];
  1355. this.month_weeks[this.current_month] = 0;
  1356. this.month_start_points.push(13);
  1357. for(var i = 0; i < no_of_weeks; i++) {
  1358. let data_group, month_change = 0;
  1359. let day = new Date(current_week_sunday);
  1360. [data_group, month_change] = this.get_week_squares_group(day, this.week_col);
  1361. this.data_groups.appendChild(data_group);
  1362. this.week_col += 1 + parseInt(this.discrete_domains && month_change);
  1363. this.month_weeks[this.current_month]++;
  1364. if(month_change) {
  1365. this.current_month = (this.current_month + 1) % 12;
  1366. this.months.push(this.current_month + '');
  1367. this.month_weeks[this.current_month] = 1;
  1368. }
  1369. this.add_days(current_week_sunday, 7);
  1370. }
  1371. this.render_month_labels();
  1372. }
  1373. get_week_squares_group(current_date, index) {
  1374. const no_of_weekdays = 7;
  1375. const square_side = 10;
  1376. const cell_padding = 2;
  1377. const step = 1;
  1378. let month_change = 0;
  1379. let week_col_change = 0;
  1380. let data_group = $.createSVG("g", {
  1381. className: "data-group",
  1382. inside: this.data_groups
  1383. });
  1384. for(var y = 0, i = 0; i < no_of_weekdays; i += step, y += (square_side + cell_padding)) {
  1385. let data_value = 0;
  1386. let color_index = 0;
  1387. let timestamp = Math.floor(current_date.getTime()/1000).toFixed(1);
  1388. if(this.data[timestamp]) {
  1389. data_value = this.data[timestamp];
  1390. color_index = this.get_max_checkpoint(data_value, this.distribution);
  1391. }
  1392. if(this.data[Math.round(timestamp)]) {
  1393. data_value = this.data[Math.round(timestamp)];
  1394. color_index = this.get_max_checkpoint(data_value, this.distribution);
  1395. }
  1396. let x = 13 + (index + week_col_change) * 12;
  1397. $.createSVG("rect", {
  1398. className: 'day',
  1399. inside: data_group,
  1400. x: x,
  1401. y: y,
  1402. width: square_side,
  1403. height: square_side,
  1404. fill: this.legend_colors[color_index],
  1405. 'data-date': this.get_dd_mm_yyyy(current_date),
  1406. 'data-value': data_value,
  1407. 'data-day': current_date.getDay()
  1408. });
  1409. let next_date = new Date(current_date);
  1410. this.add_days(next_date, 1);
  1411. if(next_date.getMonth() - current_date.getMonth()) {
  1412. month_change = 1;
  1413. if(this.discrete_domains) {
  1414. week_col_change = 1;
  1415. }
  1416. this.month_start_points.push(13 + (index + week_col_change) * 12);
  1417. }
  1418. current_date = next_date;
  1419. }
  1420. return [data_group, month_change];
  1421. }
  1422. render_month_labels() {
  1423. // this.first_month_label = 1;
  1424. // if (this.first_week_start.getDate() > 8) {
  1425. // this.first_month_label = 0;
  1426. // }
  1427. // this.last_month_label = 1;
  1428. // let first_month = this.months.shift();
  1429. // let first_month_start = this.month_start_points.shift();
  1430. // render first month if
  1431. // let last_month = this.months.pop();
  1432. // let last_month_start = this.month_start_points.pop();
  1433. // render last month if
  1434. this.months.shift();
  1435. this.month_start_points.shift();
  1436. this.months.pop();
  1437. this.month_start_points.pop();
  1438. this.month_start_points.map((start, i) => {
  1439. let month_name = this.month_names[this.months[i]].substring(0, 3);
  1440. $.createSVG('text', {
  1441. className: 'y-value-text',
  1442. inside: this.domain_label_group,
  1443. x: start + 12,
  1444. y: 10,
  1445. dy: '.32em',
  1446. innerHTML: month_name
  1447. });
  1448. });
  1449. }
  1450. make_graph_components() {
  1451. Array.prototype.slice.call(
  1452. this.container.querySelectorAll('.graph-stats-container, .sub-title, .title')
  1453. ).map(d => {
  1454. d.style.display = 'None';
  1455. });
  1456. this.chart_wrapper.style.marginTop = '0px';
  1457. this.chart_wrapper.style.paddingTop = '0px';
  1458. }
  1459. bind_tooltip() {
  1460. Array.prototype.slice.call(
  1461. document.querySelectorAll(".data-group .day")
  1462. ).map(el => {
  1463. el.addEventListener('mouseenter', (e) => {
  1464. let count = e.target.getAttribute('data-value');
  1465. let date_parts = e.target.getAttribute('data-date').split('-');
  1466. let month = this.month_names[parseInt(date_parts[1])-1].substring(0, 3);
  1467. let g_off = this.chart_wrapper.getBoundingClientRect(), p_off = e.target.getBoundingClientRect();
  1468. let width = parseInt(e.target.getAttribute('width'));
  1469. let x = p_off.left - g_off.left + (width+2)/2;
  1470. let y = p_off.top - g_off.top - (width+2)/2;
  1471. let value = count + ' ' + this.count_label;
  1472. let name = ' on ' + month + ' ' + date_parts[0] + ', ' + date_parts[2];
  1473. this.tip.set_values(x, y, name, value, [], 1);
  1474. this.tip.show_tip();
  1475. });
  1476. });
  1477. }
  1478. update(data) {
  1479. this.data = data;
  1480. this.setup_values();
  1481. this.bind_tooltip();
  1482. }
  1483. get_distribution(data={}, mapper_array) {
  1484. let data_values = Object.keys(data).map(key => data[key]);
  1485. let data_max_value = Math.max(...data_values);
  1486. let distribution_step = 1 / (mapper_array.length - 1);
  1487. let distribution = [];
  1488. mapper_array.map((color, i) => {
  1489. let checkpoint = data_max_value * (distribution_step * i);
  1490. distribution.push(checkpoint);
  1491. });
  1492. return distribution;
  1493. }
  1494. get_max_checkpoint(value, distribution) {
  1495. return distribution.filter((d, i) => {
  1496. if(i === 1) {
  1497. return distribution[0] < value;
  1498. }
  1499. return d <= value;
  1500. }).length - 1;
  1501. }
  1502. // TODO: date utils, move these out
  1503. // https://stackoverflow.com/a/11252167/6495043
  1504. treat_as_utc(date_str) {
  1505. let result = new Date(date_str);
  1506. result.setMinutes(result.getMinutes() - result.getTimezoneOffset());
  1507. return result;
  1508. }
  1509. get_dd_mm_yyyy(date) {
  1510. let dd = date.getDate();
  1511. let mm = date.getMonth() + 1; // getMonth() is zero-based
  1512. return [
  1513. (dd>9 ? '' : '0') + dd,
  1514. (mm>9 ? '' : '0') + mm,
  1515. date.getFullYear()
  1516. ].join('-');
  1517. }
  1518. get_weeks_between(start_date_str, end_date_str) {
  1519. return Math.ceil(this.get_days_between(start_date_str, end_date_str) / 7);
  1520. }
  1521. get_days_between(start_date_str, end_date_str) {
  1522. let milliseconds_per_day = 24 * 60 * 60 * 1000;
  1523. return (this.treat_as_utc(end_date_str) - this.treat_as_utc(start_date_str)) / milliseconds_per_day;
  1524. }
  1525. // mutates
  1526. add_days(date, number_of_days) {
  1527. date.setDate(date.getDate() + number_of_days);
  1528. }
  1529. get_month_name() {}
  1530. }
  1531. class SvgTip {
  1532. constructor({
  1533. parent = null
  1534. }) {
  1535. this.parent = parent;
  1536. this.title_name = '';
  1537. this.title_value = '';
  1538. this.list_values = [];
  1539. this.title_value_first = 0;
  1540. this.x = 0;
  1541. this.y = 0;
  1542. this.top = 0;
  1543. this.left = 0;
  1544. this.setup();
  1545. }
  1546. setup() {
  1547. this.make_tooltip();
  1548. }
  1549. refresh() {
  1550. this.fill();
  1551. this.calc_position();
  1552. // this.show_tip();
  1553. }
  1554. make_tooltip() {
  1555. this.container = $.create('div', {
  1556. inside: this.parent,
  1557. className: 'graph-svg-tip comparison',
  1558. innerHTML: `<span class="title"></span>
  1559. <ul class="data-point-list"></ul>
  1560. <div class="svg-pointer"></div>`
  1561. });
  1562. this.hide_tip();
  1563. this.title = this.container.querySelector('.title');
  1564. this.data_point_list = this.container.querySelector('.data-point-list');
  1565. this.parent.addEventListener('mouseleave', () => {
  1566. this.hide_tip();
  1567. });
  1568. }
  1569. fill() {
  1570. let title;
  1571. if(this.title_value_first) {
  1572. title = `<strong>${this.title_value}</strong>${this.title_name}`;
  1573. } else {
  1574. title = `${this.title_name}<strong>${this.title_value}</strong>`;
  1575. }
  1576. this.title.innerHTML = title;
  1577. this.data_point_list.innerHTML = '';
  1578. this.list_values.map((set) => {
  1579. let li = $.create('li', {
  1580. className: `border-top ${set.color || 'black'}`,
  1581. innerHTML: `<strong style="display: block;">${set.value ? set.value : '' }</strong>
  1582. ${set.title ? set.title : '' }`
  1583. });
  1584. this.data_point_list.appendChild(li);
  1585. });
  1586. }
  1587. calc_position() {
  1588. this.top = this.y - this.container.offsetHeight;
  1589. this.left = this.x - this.container.offsetWidth/2;
  1590. let max_left = this.parent.offsetWidth - this.container.offsetWidth;
  1591. let pointer = this.container.querySelector('.svg-pointer');
  1592. if(this.left < 0) {
  1593. pointer.style.left = `calc(50% - ${-1 * this.left}px)`;
  1594. this.left = 0;
  1595. } else if(this.left > max_left) {
  1596. let delta = this.left - max_left;
  1597. pointer.style.left = `calc(50% + ${delta}px)`;
  1598. this.left = max_left;
  1599. } else {
  1600. pointer.style.left = `50%`;
  1601. }
  1602. }
  1603. set_values(x, y, title_name = '', title_value = '', list_values = [], title_value_first = 0) {
  1604. this.title_name = title_name;
  1605. this.title_value = title_value;
  1606. this.list_values = list_values;
  1607. this.x = x;
  1608. this.y = y;
  1609. this.title_value_first = title_value_first;
  1610. this.refresh();
  1611. }
  1612. hide_tip() {
  1613. this.container.style.top = '0px';
  1614. this.container.style.left = '0px';
  1615. this.container.style.opacity = '0';
  1616. }
  1617. show_tip() {
  1618. this.container.style.top = this.top + 'px';
  1619. this.container.style.left = this.left + 'px';
  1620. this.container.style.opacity = '1';
  1621. }
  1622. }