Greasy Fork is available in English.

Wanikani Ultimate Timeline

Review schedule explorer for WaniKani

  1. // ==UserScript==
  2. // @name Wanikani Ultimate Timeline
  3. // @namespace rfindley
  4. // @description Review schedule explorer for WaniKani
  5. // @version 7.1.8
  6. // @match https://www.wanikani.com/
  7. // @match https://www.wanikani.com/dashboard
  8. // @match https://preview.wanikani.com/
  9. // @match https://preview.wanikani.com/dashboard
  10. // @copyright 2018-2023, Robin Findley
  11. // @license MIT; http://opensource.org/licenses/MIT
  12. // @run-at document-end
  13. // @grant none
  14. // ==/UserScript==
  15.  
  16. window.timeline = {};
  17.  
  18. (function(gobj) {
  19.  
  20. /* global $, wkof */
  21. /* eslint no-multi-spaces: "off" */
  22.  
  23. //===================================================================
  24. // Initialization of the Wanikani Open Framework.
  25. //-------------------------------------------------------------------
  26. var script_name = 'Ultimate Timeline';
  27. var wkof_version_needed = '1.1.3';
  28. if (!window.wkof) {
  29. if (confirm(script_name+' requires Wanikani Open Framework.\nDo you want to be forwarded to the installation instructions?')) {
  30. window.location.href = 'https://community.wanikani.com/t/instructions-installing-wanikani-open-framework/28549';
  31. }
  32. return;
  33. }
  34. if (wkof.version.compare_to(wkof_version_needed) === 'older') {
  35. if (confirm(script_name+' requires Wanikani Open Framework version '+wkof_version_needed+'.\nDo you want to be forwarded to the update page?')) {
  36. window.location.href = 'https://greasyfork.org/en/scripts/38582-wanikani-open-framework';
  37. }
  38. return;
  39. }
  40.  
  41. wkof.include('ItemData,Menu,Settings');
  42. wkof.ready('document,ItemData,Menu,Settings').then(load_settings).then(startup);
  43.  
  44. //===================================================================
  45. // Chart defining the auto-scaling factors of the X-axis.
  46. //-------------------------------------------------------------------
  47. var xscale = {
  48. // Scaling chart. Each column represents a scaling range,
  49. // and each row is something that we are scaling.
  50. hours_per_label: [ 1 , 3 , 6 , 12 , 24 , 48 , 720 ],
  51. red_tic_choices: ['1d','1d','1d', '1d', '1w','1ws', '1m'], // Red major tics (red label)
  52. major_tic_choices: ['1h','3h','6h','12h', '1d','1ds', '5D'], // Major tics (has label)
  53. minor_tic_choices: [ '-','1h','1h', '3h', '6h','12h', '1d'], // Minor tics (no label)
  54. bundle_choices : [ 1 , 1 , 1 , 3 , 6 , 12 , 24 ], // How many hours are bundled together.
  55. idx: 0
  56. };
  57.  
  58. //===================================================================
  59. // Interal global object for centralizing data and configuration.
  60. //-------------------------------------------------------------------
  61. var graph = {
  62. elem: null,
  63. margin: {
  64. top: 16,
  65. left: 28,
  66. bottom: 16,
  67. },
  68. x_axis: {
  69. width: 0,
  70. max_hours: 0,
  71. pixels_per_tic: 0,
  72. },
  73. y_axis: {
  74. height: 100,
  75. min_height: 80,
  76. max_height: 300,
  77. max_reviews: 0,
  78. },
  79. radical_cache: {},
  80. };
  81. gobj.graph = graph;
  82.  
  83. //===================================================================
  84. // Global utility functions.
  85. //-------------------------------------------------------------------
  86. function to_title_case(str) {return str.replace(/\w\S*/g, function(txt){return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase();});}
  87.  
  88. //===================================================================
  89. // Global variables
  90. //-------------------------------------------------------------------
  91. var settings, settings_dialog;
  92. var tz_ofs = new Date().getTimezoneOffset();
  93. var time_shift = Math.ceil(tz_ofs / 60) * 60 - tz_ofs;
  94.  
  95. //========================================================================
  96. // Load the script settings.
  97. //-------------------------------------------------------------------
  98. function load_settings() {
  99. var defaults = {
  100. minimized: false,
  101. placement: 'before_nextreview',
  102. time_format: '12hour',
  103. graph_height: 100,
  104. max_days: 14,
  105. days: 3.5,
  106. max_bar_width: 40,
  107. max_bar_height: 0,
  108. fixed_bar_height: false,
  109. bar_style: 'item_type',
  110. srs_curr_next: 'curr',
  111. current_level_markers: 'rkv',
  112. burn_markers: 'show',
  113. show_review_details: 'full',
  114. review_details_summary: 'item_type',
  115. review_details_buttons: true,
  116. show_bar_style_dropdown: true,
  117. };
  118. return wkof.Settings.load('timeline', defaults).then(function(data){
  119. settings = wkof.settings.timeline;
  120. switch (settings.show_markers) {
  121. case 'none':
  122. settings.current_level_markers = 'none';
  123. settings.burn_markers = 'hide';
  124. break;
  125. case 'curr':
  126. settings.current_level_markers = 'rkv';
  127. settings.burn_markers = 'hide';
  128. break;
  129. case 'burn':
  130. settings.current_level_markers = 'none';
  131. settings.burn_markers = 'show';
  132. break;
  133. case 'both':
  134. settings.current_level_markers = 'rkv';
  135. settings.burn_markers = 'show';
  136. break;
  137. }
  138. delete settings.show_markers;
  139. });
  140. }
  141.  
  142. //========================================================================
  143. // Startup
  144. //-------------------------------------------------------------------
  145. function startup() {
  146. install_css();
  147. install_menu_link();
  148. place_timeline(true /* first_time */);
  149. fetch_and_update();
  150. start_refresh_timer();
  151. }
  152.  
  153. //===================================================================
  154. // Install a link to the settings in the menu.
  155. //-------------------------------------------------------------------
  156. function install_menu_link()
  157. {
  158. wkof.Menu.insert_script_link({
  159. name: 'timeline',
  160. submenu: 'Settings',
  161. title: 'Ultimate Timeline',
  162. on_click: open_settings
  163. });
  164. }
  165.  
  166. //===================================================================
  167. // Top-level HTML for the script.
  168. //-------------------------------------------------------------------
  169. var timeline_html =
  170. '<section id="timeline">'+
  171. ' <h4 class="no_min">Reviews Timeline</h4>'+
  172. ' <i class="link open noselect no_min fa fa-chevron-up" title="Open the timeline"></i>'+
  173. ' <i class="link minimize noselect fa fa-chevron-down" title="Minimize the timeline"></i>'+
  174. ' <i class="link refresh noselect fa fa-refresh" title="Refresh"></i>'+
  175. ' <i class="link settings noselect fa fa-gear" title="Change timeline settings"></i>'+
  176. ' <span class="bar_style hidden"><label>Bar Style: </label><select>'+
  177. ' <option name="count">Review Count</option>'+
  178. ' <option name="item_type">Item Type</option>'+
  179. ' <option name="srs_stage">SRS Level</option>'+
  180. ' <option name="level">Level</option>'+
  181. ' </select></span>'+
  182. ' <form class="range_form" class="hidden"><label><span class="range_reviews">0</span> reviews in <span class="range_days">3 days</span> <input class="range_input" type="range" min="0.25" max="7" value="3" step="0.25" name="range_input"></label></form><br clear="all" class="no_min">'+
  183. ' <div class="graph_wrap">'+
  184. ' <div class="review_info hidden"><div class="inner"></div></div>'+
  185. ' <div class="graph_panel"></div>'+
  186. ' </div>'+
  187. '</section>';
  188.  
  189. //===================================================================
  190. // Install the style sheet for the script.
  191. //-------------------------------------------------------------------
  192. function install_css() {
  193. var timeline_css =
  194. '.noselect {-webkit-touch-callout:none; -webkit-user-select:none; -khtml-user-select:none; -moz-user-select:none; -ms-user-select:none; user-select:none; cursor:default;}'+
  195. '.dashboard section.review-status {border-top: 1px solid #ffffff;}'+
  196. '.dashboard section.review-status ul li time {white-space: nowrap; overflow-x: hidden; height: 1.5em; margin-bottom: 0;}'+
  197.  
  198. '#timeline {margin-bottom: 0px; border-bottom: 1px solid #d4d4d4;}'+
  199. '#timeline > h4 {clear:none; float:left; height:20px; margin-top:0px; margin-bottom:4px; font-weight:normal; margin-right:12px;}'+
  200. '@media (max-width: 767px) {#timeline h4 {display: none;}}'+
  201. '#timeline > .link {color:rgba(0,0,0,0.3); font-size:1.1em; text-decoration:none; cursor:pointer; margin-right:4px;}'+
  202. '#timeline > .link:hover {color:rgba(255,31,31,0.5);}'+
  203. '#timeline:not(.min) > .link.open, #timeline.min > :not(.no_min) {display:none;}'+
  204. '#timeline > .range_form {float:right; margin-bottom:0px; text-align:right;}'+
  205.  
  206. '#timeline .bar_style label {display:inline; margin-left:80px;}'+
  207. '#timeline .bar_style select {height:auto; padding:0; width:auto; vertical-align:baseline; background-color:#e3e3e3; border:1px solid #aaa; border-radius:2px;}'+
  208. '@media (max-width: 979px) {'+
  209. ' #timeline .bar_style {float:left; clear:both; margin-left:inherit;}'+
  210. ' #timeline .bar_style label {margin-left:inherit;}'+
  211. '}'+
  212. '@media (max-width: 767px) {#timeline .link {float:left;}}'+
  213.  
  214. '#timeline > .graph_panel div, #timeline > .graph_panel canvas {height:100%;width:100%;}'+
  215. '#timeline > .graph_panel div {border:1px solid #d4d4d4;}'+
  216.  
  217. '#timeline .graph_wrap {position:relative;}'+
  218.  
  219. '#timeline .review_info {position:absolute; padding-bottom:150px; z-index:5;}'+
  220. '#timeline .review_info .inner {padding:4px 8px 8px 8px; color:#eeeeee; background-color:rgba(0,0,0,0.8); border-radius:4px; font-weight:bold; z-index:2; box-sizing:border-box;}'+
  221. '#timeline .review_info .summary {font-family:"Open Sans","Helvetica Neue",Helvetica,Arial,sans-serif; font-size:13px; display:inline-block;}'+
  222. '#timeline .review_info .summary div {padding:0px 8px;}'+
  223. '#timeline .review_info .summary .indent {padding:0; margin-bottom:8px;}'+
  224. '#timeline .review_info .summary .indent:last-child {margin-bottom:0;}'+
  225. '#timeline .review_info .summary .fixed {text-align:right;}'+
  226. '#timeline .review_info .summary .tot {color:#000000; background-color:#efefef; background-image:linear-gradient(to bottom, #efefef, #cfcfcf);}'+
  227. '#timeline .review_info .items_wrap {position:relative;}'+
  228. '#timeline .summary .fixed {display:inline-block; position:relative;}'+
  229. '#timeline .review_info .summary .indent>div {display:none}'+
  230.  
  231. '#timeline .review_info[data-mode="item_type"] .summary .item_type {display:block;}'+
  232. '#timeline .review_info[data-mode="srs_stage"] .summary .srs_stage {display:block;}'+
  233. '#timeline .review_info[data-mode="level"] .summary .level {display:block;}'+
  234.  
  235. '#timeline .review_info[data-mode="count"] .item_list > li {background-color:#eee; background-image:linear-gradient(to bottom, #efefef, #cfcfcf); color:#000;}'+
  236. '#timeline .review_info[data-mode="count"] .item_list > li svg {stroke:#000;}'+
  237. '#timeline .review_info[data-mode="item_type"] .rad {background-color:#0096e7; background-image:linear-gradient(to bottom, #0af, #0093dd);}'+
  238. '#timeline .review_info[data-mode="item_type"] .kan {background-color:#ee00a1; background-image:linear-gradient(to bottom, #f0a, #dd0093);}'+
  239. '#timeline .review_info[data-mode="item_type"] .voc {background-color:#9800e8; background-image:linear-gradient(to bottom, #a0f, #9300dd);}'+
  240. '#timeline .review_info[data-mode="srs_stage"] .appr {background-color:#dd0093; background-image:linear-gradient(to bottom, #ff00aa, #b30077);}'+
  241. '#timeline .review_info[data-mode="srs_stage"] .guru {background-color:#882d9e; background-image:linear-gradient(to bottom, #aa38c7, #662277);}'+
  242. '#timeline .review_info[data-mode="srs_stage"] .mast {background-color:#294ddb; background-image:linear-gradient(to bottom, #516ee1, #2142c4);}'+
  243. '#timeline .review_info[data-mode="srs_stage"] .enli {background-color:#0093dd; background-image:linear-gradient(to bottom, #00aaff, #0077b3);}'+
  244. '#timeline .review_info[data-mode="srs_stage"] .burn {background-color:#434343; background-image:linear-gradient(to bottom, #434343, #1a1a1a);}'+
  245. '#timeline .review_info[data-mode="srs_stage"] li.burn {border:1px solid #777;}'+
  246. '#timeline .review_info[data-mode="level"] .lvlgrp0 {background-color:#5eb6e8; background-image:linear-gradient(to bottom, #5eb6e8, #1d8ac9);}'+
  247. '#timeline .review_info[data-mode="level"] .lvlgrp1 {background-color:#e25ebc; background-image:linear-gradient(to bottom, #e25ebc, #c22495);}'+
  248. '#timeline .review_info[data-mode="level"] .lvlgrp2 {background-color:#af79c3; background-image:linear-gradient(to bottom, #af79c3, #87479e);}'+
  249. '#timeline .review_info[data-mode="level"] .lvlgrp3 {background-color:#768ce7; background-image:linear-gradient(to bottom, #768ce7, #264ad9);}'+
  250. '#timeline .review_info[data-mode="level"] .lvlgrp4 {background-color:#5e5e64; background-image:linear-gradient(to bottom, #5e5e64, #313135);}'+
  251. '#timeline .review_info[data-mode="level"] .lvlgrp5 {background-color:#f5c667; background-image:linear-gradient(to bottom, #f5c667, #f0a50f); color:#333}'+
  252.  
  253. '#timeline .review_info[data-mode="level"] .lvlgrp5 svg {stroke:#333}'+
  254.  
  255. '#timeline .review_info .summary .indent>.cur {display:block; font-style:italic; color:#000000; background-color:#ffff88; background-image:linear-gradient(to bottom, #ffffaa, #eeee77);}'+
  256. '#timeline .review_info .summary .indent>.bur {display:block; font-style:italic; color:#ffffff; background-color:#000000; background-image:linear-gradient(to bottom, #444444, #000000);}'+
  257.  
  258. '#timeline .item_list {margin: 8px 0 0 0; padding: 0px;}'+
  259. '#timeline .item_list > li {padding:0 3px; margin:1px 1px; display:inline-block; border-radius:4px; font-size:14px; font-weight:normal; cursor:default; box-sizing:border-box; border:1px solid rgba(0,0,0,0);}'+
  260.  
  261. '#timeline[data-detail="full"] .item_list > li {cursor:pointer;}'+
  262. '#timeline .item_info {position:absolute; background:#333; border:8px solid rgba(0,0,0,0.7); border-radius:6px; left:4px; padding:0 8px; z-index:10;}'+
  263. '#timeline .item_info .item {font-size:2em; line-height:1.2em;}'+
  264. '#timeline .review_info wk-character-image {--color-text:#fff;display:inline-block;}'+
  265. '#timeline .item_list wk-character-image {width:1em; transform:translateY(2px); stroke-width:85;}'+
  266. '#timeline .item_info .item wk-character-image {width:28px; transform:translateY(2px);}'+
  267.  
  268. '#timeline .detail_buttons {display:inline-block; vertical-align:top; margin-left:8px;}'+
  269. '#timeline .detail_buttons button {display:block; width:130px; padding:0; margin-bottom:2px; color:#000000;}'+
  270.  
  271. '#timeline svg {overflow:hidden;}'+
  272. '#timeline svg .grid {pointer-events:none;}'+
  273. '#timeline svg .grid path {fill:none;stroke:black;stroke-linecap:square;shape-rendering:crispEdges;}'+
  274. '#timeline svg .grid .light {stroke:#ffffff;}'+
  275. '#timeline svg .grid .shadow {stroke:#d5d5d5;}'+
  276. '#timeline svg .grid .major {opacity:0.15;}'+
  277. '#timeline svg .grid .minor {opacity:0.05;}'+
  278. '#timeline svg .grid .redtic {stroke:#f22;opacity:1;}'+
  279. '#timeline svg .grid .max {stroke:#f22;opacity:0.2;}'+
  280. '#timeline svg .boundary {fill:#000;opacity:0;}'+
  281. '#timeline svg .resize_grip {fill:none;cursor:row-resize;}'+
  282. '#timeline svg .resize_grip .light {stroke:#ffffff;}'+
  283. '#timeline svg .resize_grip .shadow {stroke:#bbb;}'+
  284. '#timeline svg text.redtic {fill:#f22;font-weight:bold;}'+
  285. '#timeline svg .label-x text {text-anchor:start;font-size:0.8em;}'+
  286. '#timeline svg .label-y text {text-anchor:end;font-size:0.8em;}'+
  287. '#timeline svg text {pointer-events:none;}'+
  288. '#timeline svg .bars rect {stroke:none;shape-rendering:crispEdges;}'+
  289. '#timeline svg .bar.overlay {opacity:0;}'+
  290. '#timeline svg .bkgd {fill:#f7f7f7;}'+
  291. '#timeline svg .rad {fill:#00a1f1;}'+
  292. '#timeline svg .kan {fill:#f100a1;}'+
  293. '#timeline svg .voc {fill:#a100f1;}'+
  294. '#timeline svg .sum {fill:#294ddb;}'+
  295. '#timeline svg .appr {fill:#dd0093;}'+
  296. '#timeline svg .guru {fill:#882d9e;}'+
  297. '#timeline svg .mast {fill:#294ddb;}'+
  298. '#timeline svg .enli {fill:#0093dd;}'+
  299. '#timeline svg .burn {fill:#434343;}'+
  300. '#timeline svg .count {fill:#778ad8;}'+
  301. '#timeline svg .lvlgrp0 {fill:#5eb6e8;}'+
  302. '#timeline svg .lvlgrp1 {fill:#e25ebc;}'+
  303. '#timeline svg .lvlgrp2 {fill:#af79c3;}'+
  304. '#timeline svg .lvlgrp3 {fill:#768ce7;}'+
  305. '#timeline svg .lvlgrp4 {fill:#5e5e64;}'+
  306. '#timeline svg .lvlgrp5 {fill:#f5c667;}'+
  307. '#timeline svg .bars .cur {fill:#ffffff;opacity:0.6;}'+
  308. '#timeline svg .bars .bur {fill:#000000;opacity:0.4;}'+
  309. '#timeline svg .markers {stroke:#000000;stroke-width:0.5;}'+
  310. '#timeline svg .markers .bur {fill:#000000;}'+
  311. '#timeline svg .markers .cur {fill:#ffffff;}'+
  312. '#timeline svg .highlight .boundary {cursor:pointer;}'+
  313. '#timeline[data-detail="none"] .highlight .boundary {cursor:auto;}'+
  314. '#timeline svg .highlight .marker {pointer-events:none;shape-rendering:crispEdges;}'+
  315. '#timeline svg .highlight path.marker {fill:#00a1f1; stroke:#00a1f1; stroke-width:2;}'+
  316. '#timeline svg .highlight rect.marker {fill:rgba(0,161,241,0.1); stroke:#00a1f1; stroke-width:1;}'+
  317. '#timeline svg.link:hover * {fill:rgb(255,31,31);}'+
  318. 'body.mute_popover .popover.srs {display:none !important;}'+
  319. '';
  320.  
  321. $('head').append('<style>'+timeline_css+'</style>');
  322. }
  323.  
  324. //========================================================================
  325. // Place the timeline on the dashboard, or adjust its location on the page.
  326. //-------------------------------------------------------------------
  327. function place_timeline(first_time) {
  328. var timeline = (first_time ? $(timeline_html) : $('#timeline'));
  329. $('.progress-and-forecast').before(timeline);
  330. if (first_time) {
  331. // Initialize UI from settings
  332. graph.elem = timeline.find('.graph_panel');
  333. graph.x_axis.width = graph.elem.width() - graph.margin.left;
  334. graph.y_axis.height = settings.graph_height - (graph.margin.top + graph.margin.bottom);
  335. update_minimize();
  336. init_ui();
  337.  
  338. // Install event handlers
  339. timeline.find('.link.open, .link.minimize').on('click', toggle_minimize);
  340. timeline.find('.link.refresh').on('click', fetch_and_update);
  341. timeline.find('.link.settings').on('click', open_settings);
  342. timeline.find('.bar_style select').on('change', bar_style_changed);
  343. timeline.find('.range_input').on('input change', days_changed);
  344. timeline.find('.review_info>.inner').on('mouseenter', '.item_list > li', item_hover);
  345. timeline.find('.review_info>.inner').on('mouseleave', '.item_list', item_hover);
  346. timeline.find('.review_info>.inner').on('click', '.detail_buttons button', detail_button_clicked);
  347. timeline.find('.review_info>.inner').on('click', function(){return false;});
  348. window.addEventListener('resize', window_resized);
  349. }
  350. }
  351.  
  352. //========================================================================
  353. // Toggle whether the timeline is minimized.
  354. //-------------------------------------------------------------------
  355. function toggle_minimize() {
  356. settings.minimized = !settings.minimized;
  357. update_minimize();
  358. save_settings();
  359. }
  360.  
  361. //========================================================================
  362. // Hide or unhide the timeline when the user minimizes/restores.
  363. //-------------------------------------------------------------------
  364. function update_minimize() {
  365. var timeline = $('#timeline');
  366. var is_min = timeline.hasClass('min');
  367. if (settings.minimized && !is_min) {
  368. timeline.addClass('min');
  369. } else if (!settings.minimized && is_min) {
  370. timeline.removeClass('min');
  371. }
  372. }
  373.  
  374. //========================================================================
  375. // Update the timeline after the user changes the number of days to display.
  376. //-------------------------------------------------------------------
  377. function days_changed() {
  378. var days = Number($('#timeline .range_input').val());
  379. if (days === settings.days) return;
  380. settings.days = days;
  381. update_slider_days();
  382. bundle_by_timeslot();
  383. update_slider_reviews();
  384. draw_timeline();
  385. save_settings();
  386. }
  387.  
  388. //========================================================================
  389. // Handler for when user changes the Bar Style.
  390. //-------------------------------------------------------------------
  391. function bar_style_changed() {
  392. settings.bar_style = $('#timeline .bar_style select :selected').attr('name');
  393. draw_timeline();
  394. save_settings();
  395. }
  396.  
  397. //========================================================================
  398. // Handler for when user clicks 'Save' in the settings window.
  399. //-------------------------------------------------------------------
  400. function settings_saved() {
  401. settings = wkof.settings.timeline;
  402. place_timeline(false /* first_time */);
  403. init_ui();
  404. bundle_by_timeslot();
  405. draw_timeline();
  406. }
  407.  
  408. //========================================================================
  409. // Initialize the user interface.
  410. //-------------------------------------------------------------------
  411. function init_ui() {
  412. init_slider();
  413. if (settings.show_bar_style_dropdown) {
  414. $('#timeline .bar_style').removeClass('hidden');
  415. } else {
  416. $('#timeline .bar_style').addClass('hidden');
  417. }
  418. $('#timeline .bar_style option[name="'+settings.bar_style+'"]').prop('selected',true);
  419. $('#timeline').attr('data-detail', settings.show_review_details);
  420. $('#timeline .review_info').attr('data-mode', settings.review_details_summary);
  421. }
  422.  
  423. //========================================================================
  424. // Initialize the scale slider.
  425. //-------------------------------------------------------------------
  426. function init_slider() {
  427. var range = $('#timeline .range_input');
  428. if (settings.days > settings.max_days) {
  429. settings.days = settings.max_days;
  430. save_settings();
  431. }
  432. range.attr('max', settings.max_days);
  433. range.attr('value', settings.days);
  434. update_slider_days();
  435. }
  436.  
  437. //========================================================================
  438. // Update the 'reviews' text of the scale slider.
  439. //-------------------------------------------------------------------
  440. function update_slider_reviews() {
  441. var review_count = $('#timeline .range_reviews');
  442. review_count.text(graph.total_reviews);
  443. }
  444.  
  445. //========================================================================
  446. // Update the 'days' text of the scale slider.
  447. //-------------------------------------------------------------------
  448. function update_slider_days() {
  449. var days = settings.days;
  450. var period = $('#timeline .range_days');
  451. if (days <= 1) {
  452. period.text((days*24)+' hours');
  453. } else {
  454. period.text(days.toFixed(2)+' days');
  455. }
  456. }
  457.  
  458. //========================================================================
  459. // Save the script settings (after a 500ms delay).
  460. //-------------------------------------------------------------------
  461. var save_delay_timer;
  462. function save_settings() {
  463. if (save_delay_timer !== undefined) clearTimeout(save_delay_timer);
  464. save_delay_timer = setTimeout(function(){
  465. wkof.Settings.save('timeline');
  466. }, 500);
  467. }
  468.  
  469. //========================================================================
  470. // Handler for resizing the panel by dragging the bottom of the graph.
  471. //------------------------------------------------------------------------
  472. function resize_panel(e) {
  473. if (e.button !== 0) return;
  474. var panel = $('#timeline > .graph_panel');
  475. var start_y = e.pageY;
  476. var start_height = settings.graph_height;
  477. $('body')
  478. .addClass('mute_popover')
  479. .on('mousemove.timeline_resize touchmove.timeline_resize', function(e){
  480. var height = start_height + (e.pageY - start_y);
  481. if (height < graph.y_axis.min_height) height = graph.y_axis.min_height;
  482. if (height > graph.y_axis.max_height) height = graph.y_axis.max_height;
  483. settings.graph_height = height;
  484. graph.y_axis.height = height - (graph.margin.top + graph.margin.bottom);
  485. draw_timeline();
  486. })
  487. .on('mouseup.timeline_resize touchend.timeline_resize', function(e){
  488. save_settings();
  489. $('body').off('.timeline_resize').removeClass('mute_popover');
  490. });
  491. }
  492.  
  493. //========================================================================
  494. // Event handler for hovering over the time scale for highlighting.
  495. //------------------------------------------------------------------------
  496. var highlight = {start:0, end:0, dragging:false, highlighted: false};
  497. function highlight_hover(e) {
  498. if (settings.show_review_details === 'none') return;
  499. if (highlight.dragging) return true;
  500. var bundle_idx = nearest_bundle(e.pageX);
  501. var x;
  502. switch (e.type) {
  503. case 'mouseenter':
  504. break;
  505.  
  506. case 'mousemove':
  507. if (highlight.highlighted) return;
  508. x = bundle_to_x(bundle_idx);
  509. $('#timeline .highlight .marker.start').attr('transform', 'translate('+x+',0)');
  510. break;
  511.  
  512. case 'mouseleave':
  513. if (highlight.dragging || highlight.highlighted) return true;
  514. hide_highlight();
  515. break;
  516.  
  517. case 'touchstart':
  518. case 'mousedown':
  519. if (e.button !== 0) return;
  520. highlight.highlighted = true;
  521. highlight.dragging = true;
  522. highlight.start = bundle_idx;
  523. x = bundle_to_x(bundle_idx);
  524. $('#timeline .highlight .marker.start').attr('transform', 'translate('+x+',0)');
  525. $('#timeline .highlight .marker.end').attr('transform', 'translate(-100,0)');
  526. $('#timeline .highlight rect.marker').attr('width',0).attr('transform', 'translate('+x+',0)');
  527. $('body').on('mousemove.timeline_highlight', highlight_drag);
  528. $('body').on('touchend.timeline_highlight mouseup.timeline_highlight', highlight_release);
  529. break;
  530. }
  531. }
  532.  
  533. //========================================================================
  534. // Even handler for dragging when highlighting a time range.
  535. //------------------------------------------------------------------------
  536. function highlight_drag(e) {
  537. var bundle_idx = nearest_bundle(e.pageX);
  538. highlight.end = bundle_idx;
  539. var x1 = bundle_to_x(highlight.start);
  540. var x2 = bundle_to_x(highlight.end);
  541. $('#timeline .highlight .marker.end').attr('transform', 'translate('+x2+',0)');
  542. $('#timeline .highlight rect.marker').attr('transform', 'translate('+Math.min(x1,x2)+'.5,0.5)').attr('width',Math.abs(x2-x1));
  543. show_review_info(false /* sticky */);
  544. }
  545.  
  546. //========================================================================
  547. // Event handler for the end of a 'drag' when highlighting a time range.
  548. //------------------------------------------------------------------------
  549. function highlight_release(e) {
  550. if (e.button !== 0) return;
  551. highlight.dragging = false;
  552. $('body').off('.timeline_highlight');
  553. var bundle_idx = nearest_bundle(e.pageX);
  554. highlight.end = bundle_idx;
  555. if (highlight.start === highlight.end) {
  556. hide_highlight();
  557. } else {
  558. var x1 = bundle_to_x(Math.min(highlight.start, highlight.end));
  559. var x2 = bundle_to_x(Math.max(highlight.start, highlight.end));
  560. $('#timeline .highlight .marker.start').attr('transform', 'translate('+x1+',0)');
  561. $('#timeline .highlight .marker.end').attr('transform', 'translate('+x2+',0)');
  562. $('#timeline .highlight rect.marker').attr('transform', 'translate('+x1+'.5,0.5)').attr('width',x2-x1);
  563. highlight.highlighted = true;
  564. show_review_info(true /* sticky */);
  565. }
  566. return false;
  567. }
  568.  
  569. //========================================================================
  570. // Hide the timeline's highlight cursors.
  571. //------------------------------------------------------------------------
  572. function hide_highlight() {
  573. highlight.start = -1;
  574. highlight.end = -1;
  575. highlight.highlighted = false;
  576. $('#timeline .highlight rect.marker').attr('width',0).attr('transform', 'translate(-100,0.5)');
  577. $('#timeline .highlight .marker.start').attr('transform', 'translate(-100,0)');
  578. $('#timeline .highlight .marker.end').attr('transform', 'translate(-100,0)');
  579. hide_review_info();
  580. }
  581.  
  582. //========================================================================
  583. // nearest_bundle()
  584. //------------------------------------------------------------------------
  585. function nearest_bundle(x) {
  586. var panel_left = Math.floor($('#timeline .graph_panel').offset().left);
  587. x -= panel_left + graph.margin.left;
  588. if (x < 0) x = 0;
  589. var tic = x * graph.x_axis.max_hours / graph.x_axis.width;
  590. var bundle_idx = graph.timeslots[Math.min(graph.x_axis.max_hours-1, Math.floor(tic))];
  591. var bundle = graph.bundles[bundle_idx];
  592. var start = bundle.start_time;
  593. var end = bundle.end_time;
  594. return (tic <= ((start+end)/2) ? bundle_idx : bundle_idx+1);
  595. }
  596.  
  597. //========================================================================
  598. // Convert a bundle_idx to a graph hour offset.
  599. //------------------------------------------------------------------------
  600. function bundle_to_tic(bundle_idx) {
  601. if (bundle_idx >= graph.bundles.length) return graph.x_axis.max_hours;
  602. return graph.bundles[bundle_idx].start_time;
  603. }
  604.  
  605. //========================================================================
  606. // Convert a bundle_idx to a graph X offset.
  607. //------------------------------------------------------------------------
  608. function bundle_to_x(bundle_idx) {
  609. return Math.round(bundle_to_tic(bundle_idx) * graph.tic_spacing);
  610. }
  611.  
  612. //========================================================================
  613. // Open the settings dialog
  614. //-------------------------------------------------------------------
  615. function open_settings() {
  616. var config = {
  617. script_id: 'timeline',
  618. title: 'Ultimate Timeline',
  619. on_save: settings_saved,
  620. content: {
  621. tabs: {type:'tabset', content: {
  622. pgGraph: {type:'page', label:'Graph', hover_tip:'Graph Settings', content: {
  623. grpTime: {type:'group', label:'Time', content:{
  624. time_format: {type:'dropdown', label:'Time Format', default:'12hour', content:{'12hour':'12-hour','24hour':'24-hour', 'hours_only': 'Hours only'}, hover_tip:'Display time in 12 or 24-hour format, or hours-from-now.'},
  625. max_days: {type:'number', label:'Slider Range Max (days)', min:1, max:125, default:7, hover_tip:'Choose maximum range of the timeline slider (in days).'},
  626. }},
  627. grpBars: {type:'group', label:'Bars', content:{
  628. max_bar_width: {type:'number', label:'Max Bar Width (pixels)', default:0, hover_tip:'Set the maximum bar width (in pixels).\n(0 = unlimited)'},
  629. max_bar_height: {type:'number', label:'Max Graph Height (reviews)', default:0, hover_tip:'Set the maximum graph height (in reviews).\n(0 = unlimited)\nUseful for when you have a huge backlog.'},
  630. fixed_bar_height: {type:'checkbox', label:'Force Graph to Max Height', default:false, hover_tip:'Force the graph height to always be the Max Graph Height.\nUseful when limiting the number of reviews you do in one sitting.'},
  631. bar_style: {type:'dropdown', label:'Bar Style', default:'item_type', content:{'count':'Review Count','item_type':'Item Type','srs_stage':'SRS Level','level':'Level'}, hover_tip:'Choose how bars are subdivided.'},
  632. srs_curr_next: {type:'dropdown', label:'Current / Next SRS Level', default:'curr', content:{'curr':'Current SRS Level','next':'Next SRS Level'}, hover_tip:'Select whether SRS is color-coded by\ncurrent SRS level, or next SRS level.'},
  633. }},
  634. grpMarkers: {type:'group', label:'Markers', content:{
  635. current_level_markers: {type:'dropdown', label:'Current Level Markers', default:'rkv', content:{'none':'None','rk':'Rad + Kan','rkv':'Rad + Kan + Voc'}, hover_tip:'Select which item types will trigger a Current Level\nmarker at the bottom of the graph.'},
  636. burn_markers: {type:'dropdown', label:'Burn Markers', default:'show', content:{'show':'Show','hide':'Hide'}, hover_tip:'Select whether Burn markers are shown\nat the bottom of the graph.'},
  637. }},
  638. }},
  639. pgReviewDetails: {type:'page', label:'Review Details', hover_tip:'Review Details Pop-up', content: {
  640. show_review_details: {type:'dropdown', label:'Show Review Details', default:'full', content:{'none':'None','summary':'Summary','item_list':'Item List','full':'Full Item Details'}, hover_tip:'Choose the level of detail to display\nwhen a bar or time range is selected.'},
  641. review_details_summary: {type:'dropdown', label:'Review Details Summary', default:'item_type', content:{'count':'Review Count','item_type':'Item Type','srs_stage':'SRS Level','level':'Level'}, hover_tip:'Choose which summary information to\ndisplay on the Review Details pop-up.'},
  642. review_details_buttons: {type:'checkbox', label:'Show Review Details Buttons', default:true, hover_tip:'Show configuration buttons on Review Details pop-up.'},
  643. show_bar_style_dropdown: {type:'checkbox', label:'Show Bar Style Dropdown', default:false, hover_tip:'Show the Bar Style dropdown above the timeline.'},
  644. }},
  645. }},
  646. }
  647. };
  648. var settings_dialog = new wkof.Settings(config);
  649. settings_dialog.open();
  650. }
  651.  
  652. //========================================================================
  653. // Get the number of hours per bar.
  654. //-------------------------------------------------------------------
  655. function get_hours_per_bar() {
  656. graph.x_axis.width = graph.elem.width() - graph.margin.left;
  657. graph.x_axis.max_hours = Math.round(settings.days * 24);
  658.  
  659. // No more than 1 label every 50 pixels
  660. var min_pixels_per_label = 50;
  661. graph.min_hours_per_label = min_pixels_per_label * graph.x_axis.max_hours / graph.x_axis.width;
  662. xscale.idx = 0;
  663. while ((xscale.hours_per_label[xscale.idx] <= graph.min_hours_per_label) &&
  664. (xscale.idx < xscale.hours_per_label.length-1)) {
  665. xscale.idx++;
  666. }
  667.  
  668. return xscale.bundle_choices[xscale.idx];
  669. }
  670.  
  671. //========================================================================
  672. // Map letters in the xscale chart to corresponding label-generating functions.
  673. //-------------------------------------------------------------------
  674. var label_functions = {
  675. 'm': month_label,
  676. 'w': week_label,
  677. 'D': mday_label,
  678. 'd': day_label,
  679. 'h': hour_label,
  680. '-': no_label,
  681. };
  682.  
  683. //========================================================================
  684. // Functions for generating time-scale labels
  685. //-------------------------------------------------------------------
  686. function month_label(date, qty, use_short) {
  687. if (date.getHours() !== 0 || date.getDate() !== 1) return;
  688. return ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'][date.getMonth()];
  689. }
  690. //-------------------------------------------------------------------
  691. function week_label(date, qty, use_short) {
  692. if (date.getHours() !== 0 || date.getDay() !== 0) return;
  693. return (use_short ? 'S' : 'Sun');
  694. }
  695. //-------------------------------------------------------------------
  696. function mday_label(date, qty, use_short) {
  697. if (date.getHours() !== 0) return;
  698. var mday = date.getDate();
  699. if (mday % qty !== 0) return;
  700. return mday;
  701. }
  702. //-------------------------------------------------------------------
  703. function day_label(date, qty, use_short) {
  704. if (date.getHours() !== 0) return;
  705. var label = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][date.getDay()];
  706. return (use_short ? label[0] : label);
  707. }
  708. //-------------------------------------------------------------------
  709. function hour_label(date, qty, use_short) {
  710. var hh = date.getHours();
  711. if ((hh % qty) !== 0) return;
  712. if (settings.time_format === '24hour') {
  713. return ('0'+hh+':00').slice(-5);
  714. } else {
  715. return (((hh + 11) % 12) + 1) + 'ap'[Math.floor(hh/12)] + 'm';
  716. }
  717. }
  718. //-------------------------------------------------------------------
  719. function hour_only_label(date, qty, use_short, tic_idx) {
  720. if (tic_idx % qty !== 0) return;
  721. return tic_idx + (use_short ? 'h' : ' hrs');
  722. }
  723.  
  724. //-------------------------------------------------------------------
  725. function no_label() {return;}
  726. //-------------------------------------------------------------------
  727.  
  728. //========================================================================
  729. // Draw the timeline
  730. //-------------------------------------------------------------------
  731. function draw_timeline() {
  732. var panel = graph.elem,
  733. panel_height = settings.graph_height,
  734. panel_width = graph.elem.width(),
  735. graph_height = panel_height - (graph.margin.top + graph.margin.bottom);
  736.  
  737. var match = xscale.red_tic_choices[xscale.idx].match(/^(\d*)(.)(s?)$/);
  738. var red_qty = Number(match[1]);
  739. var red_func = label_functions[match[2]];
  740. var red_use_short = (match[3] === 's');
  741.  
  742. match = xscale.major_tic_choices[xscale.idx].match(/^(\d*)(.)(s?)$/);
  743. var maj_qty = Number(match[1]);
  744. var maj_func = label_functions[match[2]];
  745. var maj_use_short = (match[3] === 's');
  746.  
  747. match = xscale.minor_tic_choices[xscale.idx].match(/^(\d*)(.)(s?)$/);
  748. var min_qty = Number(match[1]);
  749. var min_func = label_functions[match[2]];
  750. var min_use_short = (match[3] === 's');
  751.  
  752. if (settings.time_format === 'hours_only') {
  753. red_func = function() {return 0;};
  754. maj_func = hour_only_label;
  755. min_func = hour_only_label;
  756. }
  757.  
  758. var bundle_size = xscale.bundle_choices[xscale.idx];
  759.  
  760. // String for building html.
  761. var grid = '';
  762. var label_x = [];
  763. var label_y = '';
  764. var bars = '', bar_overlays = '';
  765. var markers = '';
  766.  
  767. //=================================
  768. // Draw vertical axis grid
  769.  
  770. // Calculate major and minor vertical graph tics.
  771. var inc_s = 1, inc_l = 5;
  772. var max_reviews = graph.max_reviews;
  773. if (settings.max_bar_height > 0) {
  774. if (settings.fixed_bar_height || (max_reviews > settings.max_bar_height)) max_reviews = settings.max_bar_height;
  775. }
  776. while (Math.ceil(max_reviews / inc_s) > 5) {
  777. switch (inc_s.toString()[0]) {
  778. case '1': inc_s *= 2; inc_l *= 2; break;
  779. case '2': inc_s = Math.round(2.5 * inc_s); break;
  780. case '5': inc_s *= 2; inc_l *= 5; break;
  781. }
  782. }
  783.  
  784. // Draw vertical graph tics (# of Reviews).
  785. var tic_class, y;
  786. graph.y_axis.max_reviews = Math.max(3, Math.ceil(max_reviews / inc_s) * inc_s);
  787. for (var tic = 0; tic <= graph.y_axis.max_reviews; tic += inc_s) {
  788. tic_class = ((tic % inc_l) === 0 ? 'major' : 'minor');
  789. y = (graph.margin.top + graph_height) - Math.round(graph_height * (tic / graph.y_axis.max_reviews));
  790. if (tic > 0) {
  791. grid += '<path class="'+tic_class+'" d="M'+graph.margin.left+','+y+'h'+graph.x_axis.width+'" />';
  792. }
  793. label_y += '<text class="'+tic_class+'" x="'+(graph.margin.left-4)+'" y="'+y+'" dy="0.4em">'+tic+'</text>';
  794. }
  795.  
  796. //=================================
  797. // Draw horizontal axis grid
  798.  
  799. graph.tic_spacing = (graph.x_axis.width) / (graph.x_axis.max_hours); // Width of a time slot.
  800. var prev_label = -9e10;
  801. for (var tic_idx = 0; tic_idx < graph.x_axis.max_hours; tic_idx++) {
  802. var time = new Date(graph.start_time.getTime() + tic_idx * 3600000);
  803.  
  804. var red_label = red_func(time, red_qty, red_use_short, tic_idx);
  805. var maj_label = maj_func(time, maj_qty, maj_use_short, tic_idx);
  806. var min_label = min_func(time, min_qty, min_use_short, tic_idx);
  807.  
  808. var x = graph.margin.left + Math.round((tic_idx - time_shift/60) * graph.tic_spacing);
  809. var label;
  810. if (red_label) {
  811. if (tic_idx > 0) grid += '<path class="redtic" d="M'+x+',0v'+(graph.margin.top+graph_height-1)+'" />';
  812. if (!maj_use_short && tic_idx - prev_label < graph.min_hours_per_label*0.58) label_x.pop();
  813. label_x.push('<text class="redtic" x="'+(x+4)+'" y="'+(graph.margin.top-8)+'">'+red_label+'</text>');
  814. prev_label = tic_idx;
  815. } else if (maj_label) {
  816. if (tic_idx > 0) grid += '<path class="major" d="M'+x+',0v'+(graph.margin.top+graph_height-1)+'" />';
  817. if (maj_use_short || tic_idx - prev_label > graph.min_hours_per_label*0.58) {
  818. label_x.push('<text class="major" x="'+(x+4)+'" y="'+(graph.margin.top-8)+'">'+maj_label+'</text>');
  819. prev_label = tic_idx;
  820. }
  821. } else if (min_label) {
  822. if (tic_idx > 0) grid += '<path class="minor" d="M'+x+','+(graph.margin.top-6)+'v'+(graph_height+6)+'" />';
  823. }
  824. }
  825.  
  826. //=================================
  827. // Draw bars
  828.  
  829. var min_bar_height = Math.ceil(graph.y_axis.max_reviews / graph.y_axis.height);
  830. for (var bundle_idx in graph.bundles) {
  831. var bundle = graph.bundles[bundle_idx];
  832. var bar_parts = [];
  833. var stats = bundle.stats;
  834.  
  835. var x1 = Math.round(bundle.start_time * graph.tic_spacing);
  836. var x2 = Math.round(bundle.end_time * graph.tic_spacing);
  837. if (settings.max_bar_width > 0) x2 = Math.min(x1 + settings.max_bar_width, x2);
  838.  
  839. switch (settings.bar_style) {
  840. case 'count':
  841. if (stats.count) bar_parts.push({class:'count', height:stats.count});
  842. break;
  843.  
  844. case 'item_type':
  845. if (stats.rad) bar_parts.push({class:'rad', height:stats.rad});
  846. if (stats.kan) bar_parts.push({class:'kan', height:stats.kan});
  847. if (stats.voc) bar_parts.push({class:'voc', height:stats.voc});
  848. break;
  849.  
  850. case 'srs_stage':
  851. if (stats.appr) bar_parts.push({class:'appr', height:stats.appr});
  852. if (stats.guru) bar_parts.push({class:'guru', height:stats.guru});
  853. if (stats.mast) bar_parts.push({class:'mast', height:stats.mast});
  854. if (stats.enli) bar_parts.push({class:'enli', height:stats.enli});
  855. if (stats.burn) bar_parts.push({class:'burn', height:stats.burn});
  856. break;
  857.  
  858. case 'level':
  859. for (var grp_idx = 0; grp_idx <= 5; grp_idx++) {
  860. var grp_name = 'lvlgrp'+grp_idx;
  861. if (stats[grp_name]) bar_parts.push({class:'lvlgrp'+grp_idx, height:stats[grp_name]});
  862. }
  863. break;
  864. }
  865. var bar_offset = 0;
  866. for (var part_idx in bar_parts) {
  867. var part = bar_parts[part_idx];
  868. if ((part_idx == bar_parts.length-1) && (bar_offset + part.height < min_bar_height)) {
  869. part.height = min_bar_height - bar_offset;
  870. }
  871. bars += '<rect class="bar '+part.class+'" x="'+(x1+1)+'" y="'+bar_offset+'" width="'+(x2-x1-3)+'" height="'+part.height+'" />';
  872. bar_offset += part.height;
  873. }
  874. if (bar_parts.length > 0) {
  875. bar_overlays += '<rect class="bar overlay" x="'+x1+'" y="0" width="'+(x2-x1)+'" height="'+graph.y_axis.max_reviews+'" data-bundle="'+bundle_idx+'" />';
  876. }
  877.  
  878. var marker_x;
  879. marker_x = graph.margin.left + Math.floor((x1+x2)/2);
  880. if (bundle.stats.has_curr_marker && settings.current_level_markers !== 'none') {
  881. markers += '<path class="cur" d="M'+marker_x+','+(graph.margin.top+graph_height+1)+'l-3,6h6z" />';
  882. }
  883. if ( bundle.stats.burn_count > 0 && settings.burn_markers === 'show') {
  884. markers += '<path class="bur" d="M'+marker_x+','+(graph.margin.top+graph_height+8)+'l-3,6h6z" />';
  885. }
  886. }
  887.  
  888. //=================================
  889. // Assemble the HTML
  890.  
  891. panel.html(
  892. '<svg class="graph noselect" width="'+panel_width+'" height="'+panel_height+'">'+
  893. ' <rect class="bkgd" x="'+graph.margin.left+'" y="'+graph.margin.top+'" width="'+graph.x_axis.width+'" height="'+graph_height+'" />'+
  894. ' <g class="grid" transform="translate(0.5,0.5)">'+
  895. grid+
  896. ' <path class="shadow" d="M'+(graph.margin.left-2)+',0v'+(graph.margin.top+graph_height)+',h'+(graph.margin.left+graph.x_axis.width+1)+'" />'+
  897. ' <path class="light" d="M'+(graph.margin.left-1)+',0v'+(graph.margin.top+graph_height-1)+'" />'+
  898. ' <path class="light" d="M'+(graph.margin.left-2)+','+(graph.margin.top+graph_height+1)+'h'+(graph.margin.left+graph.x_axis.width+1)+'" />'+
  899. ' </g>'+
  900. ' <g class="label-x">'+
  901. label_x.join('')+
  902. ' </g>'+
  903. ' <g class="label-y">'+
  904. label_y+
  905. ' </g>'+
  906. ' <g class="markers" transform="translate(0.5,0.5)">'+
  907. markers+
  908. ' </g>'+
  909. ' <g class="bars" transform="translate('+graph.margin.left+','+(graph.margin.top+graph_height)+') scale(1,'+(-1 * graph_height / graph.y_axis.max_reviews)+')">'+
  910. bars+
  911. bar_overlays+
  912. ' </g>'+
  913. ' <g class="resize_grip">'+
  914. ' <path class="shadow" d="M'+(panel_width-2)+','+panel_height+'l2,-2m0,-4l-6,6m-4,0l10,-10" />'+
  915. ' <path class="light" d="M'+(panel_width-3)+','+panel_height+'l3,-3m0,-4l-7,7m-4,0l11,-11" />'+
  916. ' <rect class="boundary" x="0" y="'+(panel_height-13)+'" width="'+panel_width+'" height="13" />'+
  917. ' </g>'+
  918. ' <g class="highlight">'+
  919. ' <rect class="marker" transform="translate(-100,0.5)" x="'+graph.margin.left+'" y="'+graph.margin.top+'" width="0" height="'+graph_height+'" />'+
  920. ' <path class="marker start" transform="translate(-100,0)" d="M'+graph.margin.left+','+(graph.margin.top-1)+'l-3,-5h6l-3,5v'+(graph_height+1)+'" />'+
  921. ' <path class="marker end" transform="translate(-100,0)" d="M'+graph.margin.left+','+(graph.margin.top-1)+'l-3,-5h6l-3,5v'+(graph_height+1)+'" />'+
  922. ' <rect class="boundary" x="'+(graph.margin.left-2)+'" y="0" width="'+(graph.x_axis.width+2)+'" height="'+graph.margin.top+'" />'+
  923. ' </g>'+
  924. '</svg>'
  925. );
  926. panel.height(panel_height);
  927.  
  928. // Attach event handlers
  929. panel.find('.resize_grip .boundary').on('mousedown touchstart', resize_panel);
  930. panel.find('.highlight .boundary').on('mouseenter mouseleave mousemove mousedown touchstart', highlight_hover);
  931. panel.find('.bar.overlay').on('mouseenter mouseleave', bar_hover);
  932. panel.find('.bar.overlay').on('click', bar_click);
  933. }
  934.  
  935. //========================================================================
  936. // Event handler for clicking timeline bars.
  937. //-------------------------------------------------------------------
  938. function bar_click(e) {
  939. if (settings.show_review_details === 'none') return;
  940. if (highlight.highlighted) hide_highlight();
  941. var bundle_idx = Number(e.target.attributes['data-bundle'].value);
  942. highlight.start = bundle_idx;
  943. highlight.end = bundle_idx + 1;
  944. highlight.highlighted = true;
  945. graph.elem.off('.bar_hover_move');
  946. show_review_info(true /* sticky */, e);
  947. }
  948.  
  949. //========================================================================
  950. // Event handler for hovering over timeline bars.
  951. //-------------------------------------------------------------------
  952. function bar_hover(e) {
  953. if (settings.show_review_details === 'none') return;
  954. if (highlight.highlighted) return;
  955. switch (e.type) {
  956. case 'mouseenter':
  957. var bundle_idx = Number(e.target.attributes['data-bundle'].value);
  958. highlight.start = bundle_idx;
  959. highlight.end = bundle_idx + 1;
  960. show_review_info(false /* sticky */, e);
  961. graph.elem.on('mousemove.bar_hover_move', function(e){
  962. graph.review_info.css('top', e.clientY - e.target.getBoundingClientRect().top - 30);
  963. });
  964. break;
  965.  
  966. case 'mouseleave':
  967. graph.elem.off('.bar_hover_move');
  968. hide_review_info();
  969. break;
  970. }
  971. }
  972.  
  973. //========================================================================
  974. // Build and display the Review Info pop-up.
  975. //-------------------------------------------------------------------
  976. function show_review_info(sticky, e) {
  977. var info = $('#timeline .review_info');
  978. if (sticky) {
  979. $('body').off('.timeline_hideinfo');
  980. setTimeout(function(){
  981. $('body').on('click.timeline_hideinfo', function(e){
  982. $('body').off('.timeline_hideinfo');
  983. hide_highlight();
  984. hide_review_info();
  985. });
  986. }, 10);
  987. }
  988.  
  989. var start = Math.min(highlight.start, highlight.end);
  990. var end = Math.max(highlight.start, highlight.end);
  991.  
  992. var bundle = {items:[]};
  993. for (var bundle_idx = start; bundle_idx < end; bundle_idx++) {
  994. bundle.items = bundle.items.concat(graph.bundles[bundle_idx].items);
  995. }
  996.  
  997. calc_bundle_stats(bundle);
  998.  
  999. // Print the date or date range.
  1000. var allow_now = ((start === 0) && (graph.bundle_size === 1));
  1001. var html = '<div>';
  1002. var now = new Date();
  1003. var start_date = new Date(graph.start_time.getTime() + bundle_to_tic(start) * 3600000);
  1004. var end_date = new Date(graph.start_time.getTime() + bundle_to_tic(end) * 3600000 + (time_shift - 1) * 60000);
  1005. var same_day = (new Date(start_date).setHours(0,0,0,0) == new Date(end_date).setHours(0,0,0,0));
  1006. var show_month = ((now.getMonth() != start_date.getMonth()) || ((new Date(end_date).setHours(0,0,0,0) - new Date(now).setHours(0,0,0,0)) > (6.5 * 86400000)));
  1007. if (((end-start) > 1) || (graph.bundle_size > 1)) {
  1008. html += format_date(start_date, allow_now, true /* show_day */, show_month) + ' to ' + format_date(end_date, false, !same_day /* show_day */, show_month && !same_day);
  1009. } else {
  1010. html += format_date(start_date, allow_now, true /* show_day */, show_month);
  1011. }
  1012. html += '</div>';
  1013.  
  1014. // Populate item type summaries.
  1015. html += '<div class="summary">';
  1016. html += '<div class="tot">'+(bundle.stats.count)+' reviews</div>';
  1017. html += '<div class="indent">';
  1018.  
  1019. html += '<div class="item_type rad"><span class="fixed">'+(bundle.stats.rad || 0)+'</span> radicals</div>';
  1020. html += '<div class="item_type kan"><span class="fixed">'+(bundle.stats.kan || 0)+'</span> kanji</div>';
  1021. html += '<div class="item_type voc"><span class="fixed">'+(bundle.stats.voc || 0)+'</span> vocabulary</div>';
  1022.  
  1023. html += '<div class="srs_stage appr"><span class="fixed">'+(bundle.stats.appr || 0)+'</span> apprentice</div>';
  1024. html += '<div class="srs_stage guru"><span class="fixed">'+(bundle.stats.guru || 0)+'</span> guru</div>';
  1025. html += '<div class="srs_stage mast"><span class="fixed">'+(bundle.stats.mast || 0)+'</span> master</div>';
  1026. html += '<div class="srs_stage enli"><span class="fixed">'+(bundle.stats.enli || 0)+'</span> enlightened</div>';
  1027. if (settings.srs_curr_next === 'next') {
  1028. html += '<div class="srs_stage burn"><span class="fixed">'+(bundle.stats.burn || 0)+'</span> burn</div>';
  1029. }
  1030.  
  1031. html += '<div class="level lvlgrp0"><span class="fixed">'+(bundle.stats.lvlgrp0 || 0)+'</span> levels 1-10</div>';
  1032. html += '<div class="level lvlgrp1"><span class="fixed">'+(bundle.stats.lvlgrp1 || 0)+'</span> levels 11-20</div>';
  1033. html += '<div class="level lvlgrp2"><span class="fixed">'+(bundle.stats.lvlgrp2 || 0)+'</span> levels 21-30</div>';
  1034. html += '<div class="level lvlgrp3"><span class="fixed">'+(bundle.stats.lvlgrp3 || 0)+'</span> levels 31-40</div>';
  1035. html += '<div class="level lvlgrp4"><span class="fixed">'+(bundle.stats.lvlgrp4 || 0)+'</span> levels 41-50</div>';
  1036. html += '<div class="level lvlgrp5"><span class="fixed">'+(bundle.stats.lvlgrp5 || 0)+'</span> levels 51-60</div>';
  1037.  
  1038. html += '</div>';
  1039.  
  1040. if ((bundle.stats.curr_count > 0) || (bundle.stats.burn_count > 0)) {
  1041. html += '<div class="indent">';
  1042. if (bundle.stats.curr_count > 0) html += '<div class="cur"><span class="fixed">'+bundle.stats.curr_count+'</span> Current Level</div>';
  1043. if (bundle.stats.burn_count > 0) html += '<div class="bur"><span class="fixed">'+bundle.stats.burn_count+'</span> Burn Item'+(bundle.stats.burn_count > 1 ? 's' : '')+'</div>';
  1044. html += '</div>';
  1045. }
  1046.  
  1047. html += '</div>';
  1048.  
  1049. if (settings.review_details_buttons) {
  1050. html += '<div class="detail_buttons">';
  1051. html += '<button class="count">Review Count</button>';
  1052. html += '<button class="item_type">Item Type</button>';
  1053. html += '<button class="srs_stage">SRS Level</button>';
  1054. html += '<button class="level">Level</button>';
  1055. html += '</div>';
  1056. }
  1057.  
  1058. if (settings.show_review_details === 'item_list' || settings.show_review_details === 'full') {
  1059. html = populate_item_list(bundle, html);
  1060. }
  1061.  
  1062. info.find('.inner').html(html);
  1063. graph.review_info = info;
  1064.  
  1065. var num_width = bundle.stats.count.toString();
  1066. info.find('.summary .fixed').css('width', (num_width.toString().length * 9 + 8) + 'px');
  1067.  
  1068. var top, left, right, width;
  1069. var half_width = graph.x_axis.width/2;
  1070. var x = bundle_to_x(start);
  1071. info.css('max-width', half_width);
  1072. if (highlight.dragging) {
  1073. top = graph.margin.top + graph.y_axis.height + graph.margin.bottom;
  1074. if (x < half_width) {
  1075. left = graph.margin.left + x;
  1076. info.css({left:left, right:'auto', top:top});
  1077. } else {
  1078. right = 0;
  1079. info.css({left:'auto', right:right, top:top});
  1080. if (x < graph.x_axis.width - info.outerWidth()) {
  1081. left = graph.margin.left + x;
  1082. info.css({left:left, right:'auto', top:top});
  1083. }
  1084. }
  1085. } else if (e) {
  1086. top = e.clientY - e.target.getBoundingClientRect().top - 30;
  1087. if (x < half_width) {
  1088. left = graph.margin.left + bundle_to_x(start+1) + 4;
  1089. info.css({left:left, right:'auto', top:top});
  1090. } else {
  1091. right = graph.x_axis.width - bundle_to_x(start) + 4;
  1092. info.css({left:'auto', right:right, top:top});
  1093. }
  1094. }
  1095.  
  1096. info.removeClass('hidden');
  1097. }
  1098.  
  1099. //========================================================================
  1100. // Populate the list of items present in a time bundle.
  1101. //-------------------------------------------------------------------
  1102. function populate_item_list(bundle, html) {
  1103. var srs_to_class = {
  1104. curr: ['appr','appr','appr','appr','appr','guru','guru','mast','enli'],
  1105. next: ['appr','appr','appr','appr','guru','guru','mast','enli','burn']
  1106. };
  1107. html += '<div class="item_info hidden"></div><ul class="item_list">';
  1108. for (var item_idx in bundle.items) {
  1109. var item = bundle.items[item_idx];
  1110. var classes = [
  1111. (item.object === 'kana_vocabulary' ? 'voc' : item.object.slice(0,3)),
  1112. srs_to_class[settings.srs_curr_next][item.assignments.srs_stage],
  1113. 'lvlgrp'+Math.floor((item.data.level-1)/10)
  1114. ];
  1115. var item_name;
  1116. if (item.object === 'radical') {
  1117. if (item.data.characters !== null && item.data.characters !== '') {
  1118. html += '<li class="'+classes.join(' ')+'">'+item.data.characters+'</li>';
  1119. } else {
  1120. html += '<li class="'+classes.join(' ')+'" data-radname="'+item.data.slug+'">';
  1121. var url = item.data.character_images.filter(function(img){
  1122. return (img.content_type === 'image/svg+xml' && img.metadata.inline_styles);
  1123. })[0]?.url;
  1124. if (!url) {
  1125. html += '??';
  1126. } else {
  1127. html += '<wk-character-image src="'+url+'"></wk-character-image>';
  1128. }
  1129. html += '</li>';
  1130. }
  1131. } else {
  1132. html += '<li class="'+classes.join(' ')+'">'+item.data.slug+'</li>';
  1133. }
  1134. }
  1135. html += '</ul>';
  1136. return html;
  1137. }
  1138.  
  1139. //========================================================================
  1140. // Insert an svg into a specified DOM element.
  1141. //-------------------------------------------------------------------
  1142. function populate_radical_svg(selector, svg) {
  1143. $(selector).html(svg);
  1144. $(selector+' svg').addClass('radical');
  1145. }
  1146.  
  1147. //========================================================================
  1148. // Event handler for buttons on the Review Info pop-up.
  1149. //-------------------------------------------------------------------
  1150. function detail_button_clicked(e) {
  1151. var mode = e.target.className;
  1152. $('#timeline .review_info').attr('data-mode', mode);
  1153. settings.review_details_summary = mode;
  1154. save_settings();
  1155. }
  1156.  
  1157. //========================================================================
  1158. // Event handler for hovering over an item in the Review Detail pop-up.
  1159. //-------------------------------------------------------------------
  1160. function item_hover(e) {
  1161. if (settings.show_review_details !== 'full') return;
  1162. var info = $('#timeline .item_info');
  1163. switch (e.type) {
  1164. case 'mouseenter':
  1165. var target = $(e.currentTarget);
  1166. var item = graph.current_bundle.items[target.index()];
  1167. var pos = target.position();
  1168. info.css({top:pos.top+target.outerHeight()+3});
  1169. populate_item_info(info, item);
  1170. info.removeClass('hidden');
  1171. break;
  1172.  
  1173. case 'mouseleave':
  1174. info.addClass('hidden');
  1175. break;
  1176. }
  1177. }
  1178.  
  1179. //========================================================================
  1180. // Handler for resizing the timeline when the window size changes.
  1181. //-------------------------------------------------------------------
  1182. function window_resized() {
  1183. var new_width = graph.elem.width();
  1184. if (new_width != graph.x_axis.width + graph.margin.left) {
  1185. bundle_by_timeslot();
  1186. draw_timeline();
  1187. }
  1188. }
  1189.  
  1190. //========================================================================
  1191. // Generate the HTML content of the Item Detail pop-up.
  1192. //-------------------------------------------------------------------
  1193. var srs_stages = ['Initiate', 'Apprentice 1', 'Apprentice 2', 'Apprentice 3', 'Apprentice 4', 'Guru 1', 'Guru 2', 'Master', 'Enlightened', 'Burned']
  1194. function populate_item_info(info, item) {
  1195. var html;
  1196. switch (item.object) {
  1197. case 'radical':
  1198. if (item.data.characters !== null && item.data.characters !== '') {
  1199. html = '<span class="item">Radical: <span class="slug" lang="ja">'+item.data.characters+'</span></span><br>';
  1200. } else {
  1201. html = '<span class="item">Radical: <span class="slug" data-radname="'+item.data.slug+'">';
  1202. var url = item.data.character_images.filter(function(img){
  1203. return (img.content_type === 'image/svg+xml' && img.metadata.inline_styles);
  1204. })[0]?.url;
  1205. if (!url) {
  1206. html += '??';
  1207. } else {
  1208. html += '<wk-character-image src="'+url+'"></wk-character-image>';
  1209. }
  1210. html += '</span></span><br>';
  1211. }
  1212. break;
  1213.  
  1214. case 'kanji':
  1215. html = '<span class="item">Kanji: <span class="slug" lang="ja">'+item.data.slug+'</span></span><br>';
  1216. html += get_important_reading(item)+'<br>';
  1217. break;
  1218.  
  1219. case 'vocabulary':
  1220. html = '<span class="item">Vocab: <span class="slug" lang="ja">'+item.data.slug+'</span></span><br>';
  1221. html += 'Reading: '+get_reading(item)+'<br>';
  1222. break;
  1223.  
  1224. case 'kana_vocabulary':
  1225. html = '<span class="item">Vocab: <span class="slug" lang="ja">'+item.data.slug+'</span></span><br>';
  1226. break;
  1227. }
  1228. html += 'Meaning: '+get_meanings(item)+'<br>';
  1229. html += 'Level: '+item.data.level+'<br>';
  1230. html += 'SRS Level: '+item.assignments.srs_stage+' ('+srs_stages[item.assignments.srs_stage]+')';
  1231. info.html(html);
  1232. }
  1233.  
  1234. //========================================================================
  1235. // Load a radical's svg file.
  1236. //-------------------------------------------------------------------
  1237. function load_radical_svg(item) {
  1238. var promise = graph.radical_cache[item.data.slug];
  1239. if (promise) return promise;
  1240. if (item.data.character_images.length === 0) return promise;
  1241. var url = item.data.character_images.filter(function(img){
  1242. return (img.content_type === 'image/svg+xml' && img.metadata.inline_styles);
  1243. })[0]?.url;
  1244. promise = wkof.load_file(url);
  1245. graph.radical_cache[item.data.slug] = promise;
  1246. return promise;
  1247. }
  1248.  
  1249. //========================================================================
  1250. // Extract the meanings (including synonyms) from an item.
  1251. //-------------------------------------------------------------------
  1252. function get_meanings(item) {
  1253. var meanings = [];
  1254. if (item.study_materials && item.study_materials.meaning_synonyms) {
  1255. meanings = item.study_materials.meaning_synonyms;
  1256. }
  1257. meanings = meanings.concat(item.data.meanings.map(meaning => meaning.meaning));
  1258. return to_title_case(meanings.join(', '));
  1259. }
  1260.  
  1261. //========================================================================
  1262. // Extract the 'important' readings from a kanji.
  1263. //-------------------------------------------------------------------
  1264. function get_important_reading(item) {
  1265. var readings = item.data.readings.filter(reading => reading.primary);
  1266. return to_title_case(readings[0].type)+': '+readings.map(reading => reading.reading).join(', ');
  1267. }
  1268.  
  1269. //========================================================================
  1270. // Extract the list of readings from an item.
  1271. //-------------------------------------------------------------------
  1272. function get_reading(item) {
  1273. return item.data.readings.map(reading => reading.reading).join(', ');
  1274. }
  1275.  
  1276. //========================================================================
  1277. // Hide the Review Info pop-up.
  1278. //-------------------------------------------------------------------
  1279. function hide_review_info() {
  1280. $('#timeline .review_info').addClass('hidden');
  1281. }
  1282.  
  1283. //========================================================================
  1284. // Generate a formatted date string.
  1285. //-------------------------------------------------------------------
  1286. function format_date(time, allow_now, show_day, show_month) {
  1287. var str = '';
  1288. if (allow_now && time.getTime() >= graph.start_time.getTime()) return 'Now';
  1289. if (show_day) {
  1290. if (new Date(time).setHours(0,0,0,0) === (new Date()).setHours(0,0,0,0)) {
  1291. str = 'Today';
  1292. show_month = false;
  1293. } else {
  1294. str = 'SunMonTueWedThuFriSat'.substr(time.getDay()*3, 3);
  1295. }
  1296. if (show_month) {
  1297. str += ', ' + 'JanFebMarAprMayJunJulAugSepOctNovDec'.substr(time.getMonth()*3, 3) + ' ' + time.getDate();
  1298. }
  1299. }
  1300. if (settings.time_format === '24hour') {
  1301. str += ' ' + ('0' + time.getHours()).slice(-2) + ':' + ('0'+time.getMinutes()).slice(-2);
  1302. } else {
  1303. str += ' ' + ('0' + (((time.getHours()+11)%12)+1)).slice(-2) + ':'+('0'+time.getMinutes()).slice(-2) + 'ap'[Math.floor(time.getHours()/12)] + 'm';
  1304. }
  1305. return str;
  1306. }
  1307.  
  1308. //========================================================================
  1309. // Fetch item info, and redraw the timeline.
  1310. //-------------------------------------------------------------------
  1311. function fetch_and_update() {
  1312. return wkof.ItemData.get_items('subjects, assignments, study_materials')
  1313. .then(process_items)
  1314. .then(draw_timeline);
  1315. }
  1316.  
  1317. //========================================================================
  1318. // Process the fetched items.
  1319. //-------------------------------------------------------------------
  1320. function process_items(fetched_items) {
  1321. // Remove any unlearned items.
  1322. graph.items = [];
  1323. for (var idx in fetched_items) {
  1324. var item = fetched_items[idx];
  1325. if (!item.assignments || !item.assignments.available_at || item.assignments.srs_stage <= 0) continue;
  1326. graph.items.push(item);
  1327. }
  1328.  
  1329. graph.items.sort(function(a, b) {
  1330. return (new Date(a.assignments.available_at).getTime() - new Date(b.assignments.available_at).getTime());
  1331. });
  1332.  
  1333. bundle_by_timeslot();
  1334. update_slider_reviews();
  1335. }
  1336.  
  1337. //========================================================================
  1338. // Bundle the items into timeslots.
  1339. //-------------------------------------------------------------------
  1340. function bundle_by_timeslot() {
  1341. var bundle_size = graph.bundle_size = get_hours_per_bar();
  1342. var bundles = graph.bundles = [];
  1343. var timeslots = graph.timeslots = [];
  1344.  
  1345. // Rewind the clock to the start of a bundle period.
  1346. var start_time = toStartOfUTCHour(new Date());
  1347. while (start_time.getHours() % bundle_size !== 0) start_time = new Date(start_time.getTime() - 3600000);
  1348. graph.start_time = start_time;
  1349.  
  1350. // Find the tic of the last bundle (round down if only a partial).
  1351. graph.total_reviews = 0;
  1352. graph.max_reviews = 0;
  1353. var hour = 0, item_idx = 0, item_count = 0;
  1354. var bundle = {start_time:hour, items:[]};
  1355. while (true) {
  1356. timeslots.push(bundles.length);
  1357. hour++;
  1358. // Check if we're past end of the timeline (including rounding up to the nearest bundle)
  1359. // Need to use date function to account for time shifts (e.g. Daylight Savings Time)
  1360. var time = new Date(start_time.getTime() + hour * 3600000);
  1361. if ((time.getHours() % bundle_size) !== 0) continue;
  1362.  
  1363. var start_idx = item_idx;
  1364. while ((item_idx < graph.items.length) &&
  1365. (new Date(graph.items[item_idx].assignments.available_at) < time)) {
  1366. item_idx++;
  1367. }
  1368.  
  1369. bundle.items = graph.items.slice(start_idx, item_idx);
  1370. bundle.end_time = hour;
  1371. calc_bundle_stats(bundle);
  1372. graph.bundles.push(bundle);
  1373.  
  1374. graph.total_reviews += bundle.items.length;
  1375. if (bundle.items.length > graph.max_reviews) graph.max_reviews = bundle.items.length;
  1376. if (hour >= graph.x_axis.max_hours) break;
  1377.  
  1378. bundle = {start_time:hour, items:[]};
  1379. }
  1380. graph.x_axis.max_hours = hour;
  1381. }
  1382.  
  1383. //========================================================================
  1384. // Calculate stats for a bundle
  1385. //-------------------------------------------------------------------
  1386. function calc_bundle_stats(bundle) {
  1387. var itype_to_int = {radical:0, kanji:1, vocabulary:2};
  1388. var itype_to_class = {radical:'rad', kanji:'kan', vocabulary:'voc', kana_vocabulary:'voc'};
  1389. var srs_to_class = {
  1390. curr: ['appr','appr','appr','appr','appr','guru','guru','mast','enli'],
  1391. next: ['appr','appr','appr','appr','guru','guru','mast','enli','burn']
  1392. };
  1393. bundle.items.sort(function(a, b){
  1394. var a_itype = itype_to_int[a.object];
  1395. var b_itype = itype_to_int[b.object];
  1396. if (a_itype !== b_itype) return a_itype - b_itype;
  1397. if (a.data.level !== b.data.level) return a.data.level - b.data.level;
  1398. return a.data.slug.localeCompare(b.data.slug);
  1399. });
  1400. bundle.stats = {
  1401. count:0,
  1402. rad:0, kan:0, voc:0,
  1403. appr:0, guru:0, mast:0, enli:0, burn:0,
  1404. lvlgrp0:0, lvlgrp1:0, lvlgrp2:0, lvlgrp3:0, lvlgrp4:0, lvlgrp5:0,
  1405. curr_count: 0,
  1406. has_curr_marker: false,
  1407. burn_count: 0
  1408. };
  1409. var stats = bundle.stats;
  1410. for (var item_idx in bundle.items) {
  1411. var item = bundle.items[item_idx];
  1412. stats.count++;
  1413. stats[itype_to_class[item.object]]++;
  1414. stats[srs_to_class[settings.srs_curr_next][item.assignments.srs_stage]]++;
  1415. stats['lvlgrp'+Math.floor((item.data.level-1)/10)]++;
  1416. if (item.data.level === wkof.user.level) {
  1417. stats.curr_count++;
  1418. if (settings.current_level_markers.indexOf(itype_to_class[item.object][0]) >= 0) {
  1419. stats.has_curr_marker = true;
  1420. }
  1421. }
  1422. }
  1423. bundle.stats.burn_count = bundle.stats[srs_to_class[settings.srs_curr_next][8]];
  1424. graph.current_bundle = bundle;
  1425. }
  1426.  
  1427. //========================================================================
  1428. // Return the timestamp of the beginning of the current UTC hour.
  1429. //-------------------------------------------------------------------
  1430. function toStartOfUTCHour(date) {
  1431. var d = (date instanceof Date ? date.getTime() : date);
  1432. d = Math.floor(d/3600000)*3600000;
  1433. return (date instanceof Date ? new Date(d) : d);
  1434. }
  1435.  
  1436. //========================================================================
  1437. // Start a timer to refresh the timeline (without fetch) at the top of the hour.
  1438. //-------------------------------------------------------------------
  1439. function start_refresh_timer() {
  1440. var now = Date.now();
  1441. var next_hour = toStartOfUTCHour(now) + 3601000; // 1 second past the next UTC hour.
  1442. var wait_time = (next_hour - now);
  1443. setTimeout(function(){
  1444. bundle_by_timeslot();
  1445. update_slider_reviews();
  1446. draw_timeline();
  1447. start_refresh_timer();
  1448. }, wait_time);
  1449. }
  1450.  
  1451. })(window.timeline);