Wanikani Ultimate Timeline

Review schedule explorer for WaniKani

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