// ==UserScript== // @name WaniKani Ultimate Timeline // @namespace rfindley // @description Review schedule explorer for WaniKani // @version 6.3.0 // @include https://www.wanikani.com/ // @include https://www.wanikani.com/dashboard // @include https://www.wanikani.com/review/session* // @include https://www.wanikani.com/lesson/session* // @copyright 2015+, Robin Findley // @license MIT; http://opensource.org/licenses/MIT // @run-at document-end // @grant none // @downloadURL none // ==/UserScript== window.wktimeln = {}; (function(gobj) { var settings = { '24hour': false, 'jp_font': 'Meiryo', 'show_detail': true, 'rescale_redraw': true, 'mark_current': true, 'mark_current_vocab': true, 'graph_height': 150, 'max_days': 7, 'show_current_bars': true, 'show_burn_bars': true, 'summary_bar_only': false, 'placement': 'before_nextreview', 'minimized': false }; // A list of Japanese fonts to appear in the Settings menu. var jp_fonts = ['default','Hiragino Kaku Gothic Pro','Meiryo','Meiryo UI','Osaka','Yu Gothic','Yu Gothic UI','ヒラギノ角ゴ Pro W3','メイリオ','MS Pゴシック','sans-serif']; var levels_per_fetch = 5, graph_width_left = 28, graph_height_top = 16, graph_height_bottom = 16, max_reviews, graph_hours, graph_reviews = 0, graph_review_total = 0, graph_height_panel, graph_height, graph_width_panel, graph_width, graph_width_bar, graph_hilight_x1, graph_hilight_x2, graph_hilight_mode = 0, graph_range_slot1, graph_range_slot2, graph_detail_items, show_detail_while_dragging = true, api_key, user_level = 1, user_data, timeline, status_div, calc_time, current_slot, detail_latched = false, next_review, last_review, last_unlock, last_fetch, first_draw = true; var srslvls = ['Apprentice 1','Apprentice 2','Apprentice 3','Apprentice 4','Guru 1','Guru 2','Master','Enlightened','Burned']; var css = '#graph-bar-info {'+ ' position: absolute;'+ ' padding: 4px 8px 8px 8px;'+ ' color: #eeeeee;'+ ' background-color: rgba(0,0,0,0.8);'+ ' border-radius: 4px;'+ ' font-weight: bold;'+ ' z-index:2;'+ '}'+ '#graph-bar-info .summary {font-family:"Open Sans","Helvetica Neue",Helvetica,Arial,sans-serif; font-size:13px; max-width:140px;}'+ '#graph-bar-info .summary div {padding:0px 8px;}'+ '#graph-bar-info .summary .indent {padding:0;}'+ '#graph-bar-info .summary .indent div {padding-left:16px;}'+ '#graph-bar-info .summary .tot {color:#000000; background-color:#efefef; background-image:linear-gradient(to bottom, #efefef, #cfcfcf);}'+ '#graph-bar-info .rad {background-color:#0096e7; background-image:linear-gradient(to bottom, #0af, #0093dd);}'+ '#graph-bar-info .kan {background-color:#ee00a1; background-image:linear-gradient(to bottom, #f0a, #dd0093);}'+ '#graph-bar-info .voc {background-color:#9800e8; background-image:linear-gradient(to bottom, #a0f, #9300dd); margin-bottom:8px;}'+ '#graph-bar-info .summary .cur {text-align:center; font-style:italic; color:#000000; background-color:#ffff88; background-image:linear-gradient(to bottom, #ffffaa, #eeee77);}'+ '#graph-bar-info .summary .bur {text-align:center; font-style:italic; color:#ffffff; background-color:#000000; background-image:linear-gradient(to bottom, #444444, #000000);}'+ '#graph-bar-info .detail {margin: 8px 0 0 0; padding: 0px;}'+ '#graph-bar-info .detail li {padding:0 3px; margin:1px 1px; display:inline-block; cursor:pointer; border-radius:4px; font-size:14px;}'+ '#graph-bar-info .detail li img {height:0.8em; width:0.8em;}'+ 'div#graph-item-info {'+ ' position: absolute;'+ ' padding:8px;'+ ' color: #eeeeee;'+ ' background-color:rgba(0,0,0,0.8);'+ ' border-radius:8px;'+ ' font-family:"Open Sans","Helvetica Neue",Helvetica,Arial,sans-serif;'+ ' font-weight: bold;'+ ' z-index:3;'+ '}'+ '#graph-item-info .item {font-size:2em; line-height:1.2em;}'+ '#graph-item-info .item img {height:1em; width:1em; vertical-align:bottom;}'+ '#graph-item-info>div {padding:0 8px; background-color:#333333;}'+ 'section#timeln {margin-bottom: 0px; border-bottom: 1px solid #d4d4d4;}'+ '#timeln-graph {height:116px;}'+ '#timeln-graph div, #timeln-graph canvas {height:100%;width:100%;}'+ '#timeln-graph div {border:1px solid #d4d4d4;}'+ 'form#range_form {'+ ' float: right;'+ ' margin-bottom: 0px;'+ ' min-width: 50%;'+ ' text-align: right;'+ '}'+ 'section#timeln h4 {'+ ' clear: none;'+ ' float: left;'+ ' height: 20px;'+ ' margin-top: 0px;'+ ' margin-bottom: 4px;'+ ' font-weight: normal;'+ ' margin-right: 12px;'+ '}'+ '@media (max-width: 767px) {section#timeln h4 {display: none;}}'+ '.dashboard section.review-status {border-top: 1px solid #ffffff;}'+ '.dashboard section.review-status ul li time {white-space: nowrap; overflow-x: hidden; height: 1.5em; margin-bottom: 0;}'+ '#timeline {overflow:hidden;}'+ '#timeline .grid {pointer-events:none;}'+ '#timeline .grid polyline {fill:none;stroke:black;stroke-linecap:square;shape-rendering:crispEdges;}'+ '#timeline .grid .light {stroke:#ffffff;}'+ '#timeline .grid .shadow {stroke:#d5d5d5;}'+ '#timeline .grid .major {opacity:0.15;}'+ '#timeline .grid .minor {opacity:0.05;}'+ '#timeline .grid .newday {stroke:#f22;opacity:1;}'+ '#timeline .grid .max {stroke:#f22;opacity:0.2;}'+ '#timeline text.newday {fill:#f22;font-weight:bold;}'+ '#timeline .label-x text {text-anchor:start;font-size:0.8em;}'+ '#timeline .label-y text {text-anchor:end;font-size:0.8em;}'+ '.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;}'+ '#timeline text {pointer-events:none;}'+ '#timeline .bars rect {stroke:none;shape-rendering:crispEdges;}'+ '#timeline .rad {fill:#00a1f1;}'+ '#timeline .kan {fill:#f100a1;}'+ '#timeline .voc {fill:#a100f1;}'+ '#timeline .sum {fill:#294ddb;}'+ '#timeline .bars .cur {fill:#ffffff;opacity:0.6;}'+ '#timeline .bars .bur {fill:#000000;opacity:0.4;}'+ '#timeline .bars .clr {fill:#000000;opacity:0;cursor:pointer;}'+ '#timeline .arrows .bur {fill:#000000;stroke:#000000;stroke-width:0.5;}'+ '#timeline .arrows .cur {fill:#ffffff;stroke:#000000;stroke-width:0.5;}'+ '#timeline .hilight {pointer-events:none;}'+ '#timeline .hilight path {fill:#00a1f1; stroke:#00a1f1; stroke-width:2;}'+ '#timeline .hilight rect {fill:rgba(0,161,241,0.1); stroke:#00a1f1; stroke-width:1;}'+ '#timeln-modal {position:absolute; top:0; left:0; width:100%; height:100%; float:left; z-index:10;}'+ '#timeln .link {color:rgba(0,0,0,0.3); font-size:1.1em; text-decoration:none; cursor:pointer; margin-right:4px;}'+ '#timeln .link:hover {color:rgba(255,31,31,0.5);}'+ '#timeln svg.link:hover * {fill:rgb(255,31,31);}'+ '#timeln .dialog {'+ ' position:absolute;'+ ' padding:8px;'+ ' color:#eeeeee;'+ ' background-color:#000;'+ ' border-radius:8px;'+ ' font-weight:bold;'+ ' z-index:15;'+ '}'+ '#timeln .dialog h4 {'+ ' width:100%;'+ ' padding-bottom:4px;'+ ' border-radius:4px;'+ ' background-color:#f22;'+ ' text-align:center;'+ ' margin-bottom:20px;'+ '}'+ '#timeln .dialog button {margin:4px;}'+ '#timeln-settings label {'+ ' display:inline-block;'+ ' width:175px;'+ ' margin-right:10px;'+ ' text-align:right;'+ ' font-weight:bold;'+ '}'+ '#timeln .font_sample {line-height:26px; padding-left:5px; vertical-align:middle; font-size:14px;}'+ '#timeln:not(.min) #timeln-open, #timeln.min > *:not(.no_min) {display:none;}'+ '#timeln-settings .input {vertical-align:baseline;}'+ '#timeln-settings .input[name="jp_font"] {height:120px;}'+ '#timeln .dialog .buttons {text-align:center; margin-bottom:4px; background-color:#222; border-radius:4px;}'+ '#timeln-settings form {margin-bottom:0;}'+ '#timeln-settings {width:409px;}'+ '#timeln-help {width:450px;}'+ '#timeln-help p {font-size:12px; font-weight:normal; text-shadow:0 0; line-height:1.2em;}'+ '#timeln-help p.new_section {border-top:1px solid #777; padding-top:8px;}'+ '#timeln-help b {font-size:14px; font-weight:bold; text-shadow:0 0; color:#ff8;}' ; //------------------------------------------------------------------- // Run when 'settings' link is clicked. //------------------------------------------------------------------- function click_settings() { // Hide any other open windows. $('#timeln-help').addClass('hidden'); var t = $('#timeln').position(); var dialog = $('#timeln-settings'); // If settings dialog already exists, show it and exit. if (dialog.length > 0) { if (!dialog.is(':visible')) dialog.css('top', t.top+25).css('left', t.left+10); dialog.toggleClass('hidden'); return; } // The dialog doesn't exist. Create it. var _24hour = (get_setting('24hour') === true); var jp_font = get_setting('jp_font'); var show_detail = (get_setting('show_detail') === true); var rescale_redraw = (get_setting('rescale_redraw') === true); var graph_height = Number(get_setting('graph_height')); var max_days = Number(get_setting('max_days')); var summary_bar_only = (get_setting('summary_bar_only') === true); var show_current_bars = (get_setting('show_current_bars') === true); var show_burn_bars = (get_setting('show_burn_bars') === true); var mark_current = (get_setting('mark_current') === true); var mark_current_vocab = (get_setting('mark_current_vocab') === true); var placement = get_setting('placement'); var str; str = ''; // Add the dialog to the DOM, and display it. dialog = $(str); $('#timeln').append(dialog); dialog.css('top', t.top+25).css('left', t.left+10); dialog.removeClass('hidden'); // Set up handler for the 'Save' and 'Cancel' buttons. $('#timeln-settings button').on('click', function(e) { dialog.addClass('hidden'); var max_days = Number(get_setting('max_days')); var placement = get_setting('placement'); var save = e.target.value == 1; if (save) { set_setting('24hour', $('#timeln-settings .input[name="24hour"]').val()=="true"); set_setting('jp_font', $('#timeln-settings .input[name="jp_font"]').val()); set_setting('show_detail', $('#timeln-settings .input[name="show_detail"]').val()=="true"); set_setting('rescale_redraw', $('#timeln-settings .input[name="rescale_redraw"]').val()=="true"); set_setting('summary_bar_only', $('#timeln-settings .input[name="bar_style"]').val()=="true"); set_setting('show_current_bars', Number($('#timeln-settings .input[name="show_bars"]').val()) % 2 == 1); set_setting('show_burn_bars', Number($('#timeln-settings .input[name="show_bars"]').val()) >= 2); set_setting('mark_current', $('#timeln-settings .input[name="mark_current"]').val()!="0"); set_setting('mark_current_vocab', $('#timeln-settings .input[name="mark_current"]').val()=="2"); set_setting('graph_height', Number($('#timeln-settings .input[name="graph_height"]').val())); $('#timeln-graph').height(Number(get_setting('graph_height'))); var new_max_days = Number($('#timeln-settings .input[name="max_days"]').val()); if (new_max_days != max_days) { set_setting('max_days', new_max_days); $('#range_input').attr('max', new_max_days); } var new_placement = $('#timeln-settings .input[name="placement"]').val(); if (new_placement != placement) { set_setting('placement', new_placement); place_timeline(); } draw_timeline(); } else { var jp_font = get_setting('jp_font'); $('#timeln-settings .input[name="24hour"]').val(get_setting('24hour').toString()); $('#timeln-settings .input[name="jp_font"]').val(jp_font); $('#timeln-style').html('#timeln [lang="ja"] {font-family:'+jp_font+';}'); $('#timeln-settings .input[name="show_detail"]').val(get_setting('show_detail').toString()); $('#timeln-settings .input[name="rescale_redraw"]').val(get_setting('rescale_redraw').toString()); $('#timeln-settings .input[name="bar_style"]').val(get_setting('summary_bar_only').toString()); $('#timeln-settings .input[name="show_bars"]').val( (get_setting('show_current_bars')===true ? 1 : 0) + (get_setting('show_burn_bars')===true ? 2 : 0) ); $('#timeln-settings .input[name="mark_current"]').val( (get_setting('mark_current').toString() ? (get_setting('mark_current_vocab').toString() ? '2' : '1') : '0') ); $('#timeln-settings .input[name="graph_height"]').val(get_setting('graph_height').toString()); $('#timeln-settings .input[name="max_days"]').val(max_days.toFixed(2)); $('#timeln-settings .input[name="placement"]').val(placement); } }); // Set up handler to update the font when font selection changes. $('#timeln-settings .input[name="jp_font"]').on('change', function(e) { $('#timeln-style').html('#timeln [lang="ja"] {font-family:'+e.target.value+';}'); }); } //------------------------------------------------------------------- // Run when 'refresh' link is clicked. //------------------------------------------------------------------- function click_refresh() { clear_cache(); $('#range_form, #graph-bar-info, #graph-item-info, #timeln-help, #timeln-settings').addClass('hidden'); $('#timeline').remove(); startup(true); } //------------------------------------------------------------------- // Run when 'help' link is clicked. //------------------------------------------------------------------- function click_help() { // Hide any other open windows. $('#timeln-settings').addClass('hidden'); var t = $('#timeln').position(); var dialog = $('#timeln-help'); // If settings dialog already exists, show it and exit. if (dialog.length > 0) { if (!dialog.is(':visible')) dialog.css('top', t.top+25).css('left', t.left+10); dialog.toggleClass('hidden'); return; } // The dialog doesn't exist. Create it. str = ''; // Add the dialog to the DOM, and display it. dialog = $(str); $('#timeln').append(dialog); dialog.css('top', t.top+25).css('left', t.left+10); dialog.removeClass('hidden'); // Set up handler for the 'Ok' button. $('#timeln-help button').on('click', function(e) { dialog.addClass('hidden'); }); } //------------------------------------------------------------------- // Change the value of a setting. //------------------------------------------------------------------- function set_setting(name, value) { settings[name] = value; localStorage.setItem('timeln_settings', JSON.stringify(settings)); } //------------------------------------------------------------------- // Clear timeline data cache. //------------------------------------------------------------------- function clear_cache() { // localStorage.removeItem('timeln_username'); localStorage.removeItem('apiKey'); localStorage.removeItem('timeln_cache'); localStorage.removeItem('timeln_last_fetch'); localStorage.removeItem('timeln_last_review'); } //------------------------------------------------------------------- // Retrieve the value of a setting. //------------------------------------------------------------------- function get_setting(name) { return settings[name]; } //------------------------------------------------------------------- // Close the modal window. //------------------------------------------------------------------- function close_modal() { $('#timeln-modal').remove(); } //------------------------------------------------------------------- // Set up a full-screen modal window at z-index-10 to catch events. //------------------------------------------------------------------- function open_modal(events, handler) { var modal = $('
'); modal.height($(document).height()); $('body').prepend(modal); modal.on(events, handler); } //------------------------------------------------------------------- // Event handler for item details. //------------------------------------------------------------------- function item_info_event(e) { var hinfo = $('#graph-item-info'); var target = $(e.target); switch (e.type) { //----------------------------- case 'mouseenter': var item; var type = target.data('type'); var str = '
'; switch (type) { case 'rad': item = graph_detail_items.radicals[target.data('idx')]; str += 'Item: '; if (item.character !== null) str += item.character+'
'; else str += '
'; str += 'Meaning: '+toTitleCase(item.meaning)+'
'; str += 'Level: '+item.level+'
'; str += 'SRS Level: '+srslvls[item.user_specific.srs_numeric-1]+'
'; break; case 'kan': item = graph_detail_items.kanji[target.data('idx')]; str += 'Item: '+item.character+'
'; str += toTitleCase(item.important_reading)+': '+item[item.important_reading]+'
'; str += 'Meaning: '+toTitleCase(item.meaning)+'
'; if (item.user_specific.user_synonyms !== null && item.user_specific.user_synonyms.length > 0) str += 'Synonyms: '+toTitleCase(item.user_specific.user_synonyms.join(', '))+'
'; str += 'Level: '+item.level+'
'; str += 'SRS Level: '+srslvls[item.user_specific.srs_numeric-1]+'
'; break; case 'voc': item = graph_detail_items.vocabulary[target.data('idx')]; str += 'Item: '+item.character+'
'; str += 'Reading: '+item.kana+'
'; str += 'Meaning: '+toTitleCase(item.meaning)+'
'; if (item.user_specific.user_synonyms !== null && item.user_specific.user_synonyms.length > 0) str += 'Synonyms: '+toTitleCase(item.user_specific.user_synonyms.join(', '))+'
'; str += 'Level: '+item.level+'
'; str += 'SRS Level: '+srslvls[item.user_specific.srs_numeric-1]+'
'; break; } str += '
'; hinfo.html(str); hinfo.css('left', target.offset().left - target.position().left); hinfo.css('top', Math.floor(target.offset().top + target.outerHeight() + 3)); hinfo.removeClass('hidden'); break; //----------------------------- case 'mouseleave': hinfo.addClass('hidden'); break; } } //------------------------------------------------------------------- // Generate a formatted date string. //------------------------------------------------------------------- function format_date(time) { var str; if (time.getTime() === calc_time) return 'Now'; if (time.getDate() === (new Date()).getDate()) str = 'Today'; else str = 'SunMonTueWedThuFriSat'.substr(time.getDay()*3, 3); if (settings['24hour']) str += ' ' + ('0' + time.getHours()).slice(-2) + ':' + '00153045'.substr(Math.floor(time.getMinutes()/15)*2, 2); else str += ' ' + ('0' + (((time.getHours()+11)%12)+1)).slice(-2) + ':' + '00153045'.substr(Math.floor(time.getMinutes()/15)*2, 2) + 'ap'[Math.floor(time.getHours()/12)] + 'm'; return str; } //------------------------------------------------------------------- // Populate the info box. //------------------------------------------------------------------- function populate_info(slot_idx1, slot_idx2, hide_detail) { // Check arguments, assign default values when missing. if (slot_idx2 === undefined) slot_idx2 = slot_idx1+1; if (hide_detail === undefined) hide_detail = false; // Consolidate the selected range into a single structure of review items. var si; var si1 = Math.min(slot_idx1, slot_idx2); var si2 = Math.max(slot_idx1, slot_idx2); var hinfo = $('#graph-bar-info'); var slot_sum = {radicals:[], kanji:[], vocabulary:[], item_count:0, has_current:false, has_burn:false}; for (si = si1; si < si2; si++) { var slot = timeline[si]; if (slot === undefined) continue; slot_sum.radicals = slot_sum.radicals.concat(slot.radicals); slot_sum.kanji = slot_sum.kanji.concat(slot.kanji); slot_sum.vocabulary = slot_sum.vocabulary.concat(slot.vocabulary); slot_sum.item_count += slot.item_count; slot_sum.has_current |= slot.has_current; slot_sum.has_burn |= slot.has_burn; } // If no items are in the range, hide the detail window. if (slot_sum.item_count === 0) { hinfo.addClass('hidden'); return; } // Save a global copy of the consolidated info for use in support functions. graph_detail_items = slot_sum; // Print the date or date range). var str = format_date(new Date(calc_time + si1 * 900000)); if (si2-si1 > 1) str += ' to ' + format_date(new Date(calc_time + si2 * 900000)); // Populate item type summaries. str += '
'; str += '
'+(slot_sum.radicals.length+slot_sum.kanji.length+slot_sum.vocabulary.length)+' reviews
'; str += '
'; str += '
'+slot_sum.radicals.length+' radicals
'; str += '
'+slot_sum.kanji.length+' kanji
'; str += '
'+slot_sum.vocabulary.length+' vocabulary
'; str += '
'; if (slot_sum.has_current) str += '
Current Level
'; if (slot_sum.has_burn) str += '
Burn Items
'; str += '
'; // If details are enabled, populate the review-item list. var idx, item; var show_detail = get_setting('show_detail') && !hide_detail; if (show_detail) { str += ''; } // We are done building the info box. Add it to the DOM. hinfo.css('max-width', $('#timeln-graph').width()/2 - 15); hinfo.html(str); // Add event handlers for hovering review items. if (show_detail) { $('#timeln .detail').on('mouseenter', 'li', item_info_event); $('#timeln .detail').on('mouseleave', item_info_event); } // If we are displaying a range, position the info box below the timeline. // If the user is just hovering over a timeslot, the info box is positioned in a different function. if (graph_hilight_mode != 0) { var tlpos = $('#timeline')[0].getBoundingClientRect(); if (si1 <= graph_hours*2) hinfo.css('left', tlpos.left + Math.floor(Math.min(graph_hilight_x1,graph_hilight_x2))); else hinfo.css('left', tlpos.left + Math.floor(Math.max(graph_hilight_x1,graph_hilight_x2)) - hinfo.outerWidth()); hinfo.css('top', tlpos.top + window.scrollY + graph_height_panel); } hinfo.removeClass('hidden'); } //------------------------------------------------------------------- // Event handler for time slots. //------------------------------------------------------------------- function bar_events(e) { // Don't accept events while user is selecting a range. if (detail_latched || graph_hilight_mode != 0) return; var hinfo = $('#graph-bar-info'); var target = $(e.target); var slot_idx = target.data('slot'); switch (e.type) { //----------------------------- case 'mousemove': e.stopPropagation(); // We only want to redraw the info box just as we enter a new time slot. if (slot_idx !== current_slot) { current_slot = slot_idx; // Populate the info box. populate_info(slot_idx); // Set the info box position, and unhide. var left = e.target.getBoundingClientRect().left; if (slot_idx < graph_hours*2) hinfo.css('left', Math.floor(left + e.target.width.baseVal.value)+3); else hinfo.css('left', Math.floor(left - hinfo.outerWidth())-2); } // Update the vertical position even if we're on the // same time slot, so box follows cursor vertically. hinfo.css('top', e.pageY - 30); break; //----------------------------- case 'mouseleave': hinfo.addClass('hidden'); current_slot = undefined; break; //----------------------------- case 'click': detail_latched = true; populate_info(slot_idx); e.stopPropagation(); // Wait for a click anywhere on the document to close the info window. $('body').on('click.close_tip', function(e) { // Ignore clicks on the info box itself. var tip = $('#graph-bar-info')[0]; if (e.target === tip || $.contains(tip, e.target)) return; // Click was outside of info box. Close info box. detail_latched = false; current_slot = undefined; hinfo.addClass('hidden'); $('body').off('.close_tip'); // If we clicked on another slot, make info sticky for that slot // by simulating an additional click on that item. if ($.contains($('#timeline .bars')[0], e.target)) { var t = $(e.target); t.trigger('mousemove'); t.trigger('click'); } }); break; } } //------------------------------------------------------------------- // Event handler for overall graph. //------------------------------------------------------------------- function graph_events(e) { var m1 = $('#timeline .marker:nth(0)'); var mr = $('#timeline .hilight rect') var m2 = $('#timeline .marker:nth(1)'); var hinfo = $('#graph-bar-info'); switch (e.type) { //----------------------------- case 'mousemove': e.stopPropagation(); var x = e.offsetX - graph_width_left; if ((e.offsetY > graph_height_top || x < 0 || x >= graph_width) && graph_hilight_mode < 2) { graph_hilight_mode = 0; m1.attr('transform','translate(0 -1000)'); m2.attr('transform','translate(0 -1000)'); mr.attr('y','-1000'); break; } var slot = Math.round(x / graph_width_bar); if (slot < 0) slot = 0; if (slot > graph_hours*4) slot = graph_hours*4; x = Math.floor(slot * graph_width_bar) + graph_width_left; switch (graph_hilight_mode) { //----------------------------- case 0: // Idle mode, but mouse just entered the area for selecting range. graph_hilight_mode++; // Fall-through //----------------------------- case 1: // User is inside the area for selecting range. Display 'start' marker. graph_range_slot1 = graph_range_slot2 = slot; graph_hilight_x1 = graph_hilight_x2 = x; m1.attr('transform','translate('+x+' '+graph_height_top+')'); break; //----------------------------- case 2: // User is dragging a range selection. if (graph_range_slot2 === slot) break; m2.attr('transform','translate('+x+' '+graph_height_top+')'); graph_range_slot2 = slot; graph_hilight_x2 = x; mr.attr('x',Math.min(graph_hilight_x1,graph_hilight_x2)).attr('y',graph_height_top) mr.attr('width',Math.floor(Math.abs(graph_hilight_x2-graph_hilight_x1))); populate_info(graph_range_slot1, graph_range_slot2, !show_detail_while_dragging); break; } break; //----------------------------- case 'mousedown': if (e.button != 0) break; // Only left mouse button switch (graph_hilight_mode) { //----------------------------- case 1: // User is in area for selecting range, and just clicked to start selecting. graph_hilight_mode = 2; e.preventDefault(); e.stopPropagation(); var timeln_x = $('#timeline').offset().left; open_modal('mousemove mousedown mouseup', function(e) { e.offsetX -= timeln_x; graph_events(e); }); break; //----------------------------- case 2: // User clicked for 'end' range. (No longer used, since only click-drag-release is supported.) $('#timeln-modal').css('z-index',1); graph_hilight_mode = 3; populate_info(graph_range_slot1, graph_range_slot2, false); break; //----------------------------- case 3: // Range was already selected. Either close existing range, or start new range. graph_hilight_mode = 0; m1.attr('transform','translate(0 -1000)'); m2.attr('transform','translate(0 -1000)'); mr.attr('y','-1000'); hinfo.addClass('hidden'); close_modal(); // If user clicked again on timeline bar, start new range selection. var t = $('#timeline'); var tx1 = graph_width_left; var tx2 = tx1 + graph_width; var ty1 = t.offset().top; var ty2 = ty1 + graph_height_top; var cx = e.offsetX; var cy = e.offsetY; if (cx >= tx1 && cx < tx2 && cy >= ty1 && cy < ty2) { graph_hilight_mode = 1; e.target = t[0]; e.offsetY -= t.offset().top; e.type = 'mousemove'; graph_events(e); e.type = 'mousedown'; graph_events(e); } else { } break; } break; //----------------------------- case 'mouseup': if (e.button != 0) break; // Only left mouse button if (graph_hilight_mode != 2) break; // Only process release during drag. // Check if user dragged, or only clicked. if (graph_range_slot1 !== graph_range_slot2) { $('#timeln-modal').css('z-index',1); graph_hilight_mode = 3; populate_info(graph_range_slot1, graph_range_slot2, false); } else { graph_hilight_mode = 1; m2.attr('transform','translate(0 -1000)'); mr.attr('y','-1000'); hinfo.addClass('hidden'); close_modal(); } break; //----------------------------- case 'mouseleave': // User wasn't in the process of selecting a range, and the mouse // left the area for selecting a range. Hide 'start' marker. if (graph_hilight_mode < 2) { m1.attr('transform','translate(0 -1000)'); m2.attr('transform','translate(0 -1000)'); mr.attr('y','-1000'); graph_hilight_mode = 0; } break; } } //------------------------------------------------------------------- // Event handler for hours slider. //------------------------------------------------------------------- function change_hours(e) { graph_hours = Math.round(Number($('#range_input').val())*24); localStorage.setItem('timeln_graph_hours', graph_hours); $('#range_days').text(slider_label(graph_hours)); if (e.type === 'change' || get_setting('rescale_redraw')) draw_timeline(); } //------------------------------------------------------------------- // Draw the timeline. //------------------------------------------------------------------- function draw_timeline() { // Need to 'restore' before redrawing. if (get_setting('minimized')) $('#timeln').removeClass('min'); // Do some cleanup, in case redraw was triggered by 15min timer. $('#graph-bar-info, #graph-bar-info').addClass('hidden'); close_modal(); graph_hilight_mode = 0; // Update our timeline data based on cache. calc_timeline(); // If cache says we have available items, but WK says next review // date is in the future, user must have done reviews on another // device. Need to force refresh. var now = Math.floor(new Date()/1000); if (first_draw === true && timeline[0] !== undefined && next_review >= Math.ceil(now/900)*900) { first_draw = false; setTimeout(click_refresh, 50); // Refresh after finishing main() return; } // Update slider label with number of reviews on graph. $('#range_reviews').text(graph_review_total); // Calculate graph dimensions. var timeln_graph = $('#timeln-graph'); $('#timeline').remove(); graph_height_panel = timeln_graph.height(); graph_height = graph_height_panel - (graph_height_top + graph_height_bottom); graph_width_panel = timeln_graph.width(); graph_width = graph_width_panel - graph_width_left; // String for building html. var grid = ''; var label_x = ''; var label_y = ''; var bars = ''; var arrows = ''; // Calculate major and minor vertical graph tics. var inc_s = 1, inc_l = 5; while (Math.ceil(max_reviews / inc_s) > 5) { switch (inc_s.toString()[0]) { case '1': inc_s *= 2; inc_l *= 2; break; case '2': inc_s = Math.round(2.5 * inc_s); break; case '5': inc_s *= 2; inc_l *= 5; break; } } // Draw vertical graph tics (# of Reviews). var tic, tic_class, y; graph_reviews = Math.ceil(max_reviews / inc_s) * inc_s; for (tic = 0; tic <= graph_reviews; tic += inc_s) { tic_class = ((tic % inc_l) === 0 ? 'major' : 'minor'); y = (graph_height_top + graph_height) - Math.round(graph_height * (tic / graph_reviews)); if (tic > 0) grid += ''; label_y += ''+tic+''; } // Set up to draw horizontal graph tics (Time). var major_tic_choices = [1, 3, 6, 12, 24]; // Hours var minor_tic_choices = [1, 4, 4, 12, 24]; // 15min intervals var max_labels = Math.floor(graph_width / 50); // No more than 1 label every 50 pixels var tic_choice = 0; while ((graph_hours / major_tic_choices[tic_choice]) > max_labels && tic_choice < major_tic_choices.length) tic_choice++; var major_tic = major_tic_choices[tic_choice] * 4; var minor_tic = minor_tic_choices[tic_choice]; // Draw grid tics, and populate datapoints var tic_ofs = Math.floor((calc_time - (new Date(calc_time)).setHours(0, 0, 0, 0)) / 900000); graph_width_bar = (graph_width-1) / (graph_hours*4); // Width of a time slot. for (tic = 0; tic <= graph_hours*4; tic++) { var x = Math.floor(graph_width_left + tic * graph_width_bar); // Need to use date function to account for time shifts (e.g. Daylight Savings Time) tstamp = new Date(calc_time + tic * 900000); var hh = tstamp.getHours(); var qh = hh*4 + Math.round(tstamp.getMinutes()/15); // Check if we are on a Major Tic mark if (qh % major_tic === 0) { // Start of a new day? if (hh === 0) { tic_class = 'newday'; label = 'SunMonTueWedThuFriSat'.substr(tstamp.getDay()*3, 3); } else { tic_class = 'major'; tstamp = new Date(calc_time + tic * 900000); var hh = tstamp.getHours(); if (settings['24hour']) { label = ('0'+hh+':00').slice(-5); } else { label = (((hh + 11) % 12) + 1) + 'ap'[Math.floor(hh/12)] + 'm'; } } if (tic > 0) grid += ''; label_x += ''+label+''; } else if (qh % minor_tic === 0) { // Minor Tic mark if (tic > 0) grid += ''; } // If there are reviews for the current timeslot, draw graph bars. var slot = timeline[tic]; if (slot && tic < graph_hours*4) { var x1 = x - graph_width_left; var x2 = Math.floor((tic+1) * graph_width_bar); var base = 0; var rad = slot.radicals.length; var kan = slot.kanji.length; var voc = slot.vocabulary.length; if (get_setting('summary_bar_only')) { bars += ''; base += rad+kan+voc; } else { if (rad > 0) { bars += ''; base += rad; } if (kan > 0) { bars += ''; base += kan; } if (voc > 0) { bars += ''; base += voc; } } // If current timeslot has current-level items, or items ready for Burning, draw indicator arrows. var ay = graph_height_top+graph_height+1; var ac = graph_width_left+(x1+x2)/2; var ah = 7; var aw = Math.min(x2-x1, ah); var ax1 = ac-(aw/2); var ax2 = ax1+aw; if (slot.has_current) { if (get_setting('show_current_bars')) bars += ''; arrows += ''; } ay += ah+1; if (slot.has_burn) { if (get_setting('show_burn_bars') && !(slot.has_current && get_setting('show_current_bars'))) bars += ''; arrows += ''; } bars += ''; } } // Build the html/svg object from the components build above. timeln_graph.append( ''+ ' '+ grid+ ' '+ ' '+ ' '+ ' '+ ' '+ ' '+ label_x+ ' '+ ' '+ label_y+ ' '+ ' '+ arrows+ ' '+ ' '+ ' '+ bars+ ' '+ ' '+ ' '+ //RJF ' '+ ' '+ ' '+ ' '+ '' ); // Add event handlers for the graph. $('#timeline .bars .clr').on('mousemove mouseleave click', bar_events); $('#timeline').on('mousemove mousedown mouseup mouseleave', graph_events); // Schedule next timeline update, 1sec after next qtr hour. var next_time = (new Date()).getTime(); next_time = Math.ceil(next_time/900000)*900000 - next_time + 1000; setTimeout(function() { draw_timeline(); }, next_time); // Need to 'restore' before redrawing. if (get_setting('minimized')) $('#timeln').addClass('min'); } //------------------------------------------------------------------- // Generate timeline data. //------------------------------------------------------------------- function calc_timeline() { calc_time = new Date(); calc_time = calc_time.setMinutes(Math.floor(calc_time.getMinutes()/15)*15, 0, 0); var next_time = Math.ceil(calc_time/900000); // Timestamp of next 15min slot max_reviews = 3; graph_review_total = 0; var max_slot = graph_hours*4; timeline = []; types = ['radicals', 'kanji', 'vocabulary']; var mark = { radicals: (get_setting('mark_current') === true), kanji: (get_setting('mark_current') === true), vocabulary: ((get_setting('mark_current') === true) && (get_setting('mark_current_vocab') === true)) }; for (var type_idx in types) { var type = types[type_idx]; var item_cnt = user_data[type].length; var mark_current = mark[type]; for (var item_idx = 0; item_idx < item_cnt; item_idx++) { var item = user_data[type][item_idx]; var item_time = Math.floor(item.user_specific.available_date / 900); // Round down to 15min. var slot_idx = Math.min(max_slot, Math.max(0, item_time - next_time)); if (timeline[slot_idx] === undefined) timeline[slot_idx] = {radicals:[], kanji:[], vocabulary:[], item_count:0, has_current:false, has_burn:false, item_time:item_time*900}; var slot = timeline[slot_idx]; slot.item_count++; if (slot_idx < max_slot) { graph_review_total++; if (slot.item_count > max_reviews) max_reviews = slot.item_count; } slot[type].push(item); if (mark_current && item.level == user_level) slot.has_current = true; if (item.user_specific.srs_numeric == 8) slot.has_burn = true; } } gobj.timeline = timeline; } //------------------------------------------------------------------- // Make first letter of each word upper-case. //------------------------------------------------------------------- function toTitleCase(str) { return str.replace(/\w\S*/g, function(txt){return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase();}); } //------------------------------------------------------------------- // Add a '+ '

Reviews Timeline

'+ ' '+ ' '+ ' '+ ' '+ ' '+ '
'+ ' '+ ' '+ '
'+ '' ); $('#timeln-open, #timeln-minimize').on('click', function(e){ e.preventDefault(); set_setting('minimized', !get_setting('minimized')); $('#timeln').toggleClass('min'); }); $('#timeln-settings-lnk').on('click', click_settings); $('#timeln-refresh-lnk').on('click', click_refresh); $('#timeln-help-lnk').on('click', click_help); $('#timeln-graph').height(Number(get_setting('graph_height'))); } // Gather some info to help determine cache status. user_level = Number($('.levels span:nth(0)').text()); next_review = Number($('.review-status .timeago').attr('datetime')); last_review = Number(localStorage.getItem('timeln_last_review') || 0); last_unlock = new Date($('.recent-unlocks time:nth(0)').attr('datetime'))/1000; last_fetch = Number(localStorage.getItem('timeln_last_fetch') || 0); // Workaround for "WaniKani Real Times" script, which deletes the element we were looking for above. if (isNaN(next_review)) { next_review = Number($('.review-status time1').attr('datetime')); // Conditional divide-by-1000, in case someone fixed this error in Real Times script. if (next_review > 10000000000) next_review /= 1000; } // Fetch API key and update cache, only if necessary. var promise; status_div = $('#timeln-status'); if (last_fetch <= last_unlock || last_fetch <= last_review || (next_review < now && last_fetch <= (now-3600))) { status_div.removeClass('hidden'); status_div.html('Failed to fetch API key!'); promise = get_api_key() .then(get_review_data) .catch(function(e){status_div.html(e.message);}); } else { promise = Promise.resolve(); } // We have an up-to-date cache. Draw the timeline. promise.then(function(){ // Fetch user data from cache. user_data = JSON.parse(localStorage.getItem('timeln_cache')); // Draw the timeline. status_div.addClass('hidden'); draw_timeline(); // Install event handlers for time range slider. $('#range_input').on('input change', change_hours); $('#range_form').removeClass('hidden'); },null); } // Run startup() after window.onload event. if (document.readyState === 'complete') startup(); else window.addEventListener("load", startup, false); })(wktimeln);