Skip to content

Instantly share code, notes, and snippets.

@Atonement100
Created February 15, 2018 05:17
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Atonement100/01ac1ad56ab46b828120ddda898a1007 to your computer and use it in GitHub Desktop.
Save Atonement100/01ac1ad56ab46b828120ddda898a1007 to your computer and use it in GitHub Desktop.
wkultimate srs timeline
// ==UserScript==
// @name WaniKani Ultimate Timeline
// @namespace rfindley
// @description Review schedule explorer for WaniKani
// @version 6.5.1
// @include https://www.wanikani.com/
// @include https://www.wanikani.com/dashboard
// @include https://www.wanikani.com/settings/account
// @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
// ==/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,
'show_srs_timeline': true
};
// 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 = 15,
ms_betw_fetches = 250,
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 .app1 {background-color:#9800e8; background-image:linear-gradient(to bottom, #a0f, #9300dd);}'+
'#graph-bar-info .app2 {background-color:#9800e8; background-image:linear-gradient(to bottom, #a0f, #9300dd);}'+
'#graph-bar-info .app3 {background-color:#9800e8; background-image:linear-gradient(to bottom, #a0f, #9300dd);}'+
'#graph-bar-info .app4 {background-color:#9800e8; background-image:linear-gradient(to bottom, #a0f, #9300dd);}'+
'#graph-bar-info .guru1 {background-color:#9800e8; background-image:linear-gradient(to bottom, #a0f, #9300dd);}'+
'#graph-bar-info .guru2 {background-color:#9800e8; background-image:linear-gradient(to bottom, #a0f, #9300dd);}'+
'#graph-bar-info .master {background-color:#9800e8; background-image:linear-gradient(to bottom, #a0f, #9300dd);}'+
'#graph-bar-info .enlightened {background-color:#9800e8; background-image:linear-gradient(to bottom, #a0f, #9300dd);}'+
'#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 .app1 {fill:#F270A4;}'+
'#timeline .app2 {fill:#ED4488;}'+
'#timeline .app3 {fill:#EA1C6F;}'+
'#timeline .app4 {fill:#B3004B;}'+
'#timeline .guru1 {fill:#43A6D1;}'+
'#timeline .guru2 {fill:#0686BE;}'+
'#timeline .master {fill:#045D84;}'+
'#timeline .enlightened {fill:#FFA949;}'+
'#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 =
'<div id="timeln-settings" class="dialog hidden">'+
' <h4>Timeline Settings</h4>'+
' <form>'+
' <label title="Display time in 24-hour mode (\'15:45\'), or 12-hour (\'3:45pm\').">Time Format</label>'+
' <select class="input" name="24hour" title="Display time in 24-hour mode (\'15:45\'), or 12-hour (\'3:45pm\').">'+
' <option value="false" '+(_24hour ? '' : 'selected')+'>12-hour</option>'+
' <option value="true" '+(_24hour ? 'selected' : '')+'>24-hour</option>'+
' </select>'+
' <label title="Sample text for previewing how Japanese text will be displayed.">Font Sample</label><span class="font_sample" lang="ja" title="Sample text for previewing how Japanese text will be displayed.">日本語を勉強していますか?</span><br />'+
' <label title="Choose the font to use for displaying Japanese text.">Japanese Font</label>'+
' <select class="input" name="jp_font" size="'+jp_fonts.length+'" style="vertical-align:top;" title="Choose the font to use for displaying Japanese text.">';
for (var idx = 0; idx < jp_fonts.length; idx++)
str += '<option value="'+jp_fonts[idx]+'"'+(jp_font === jp_fonts[idx] ? 'selected' : '')+'>'+jp_fonts[idx]+'</option>';
str +=
' </select>'+
' <label title="Show detailed contents of upcoming reviews.">Show Review Details</label>'+
' <select class="input" name="show_detail" title="Show detailed contents of upcoming reviews.">'+
' <option value="true" '+(show_detail ? 'selected' : '')+'>Yes</option>'+
' <option value="false" '+(show_detail ? '' : 'selected')+'>No</option>'+
' </select>'+
' <label title="Redraw the timeline while adjusting the timescale.">Redraw While Scaling</label>'+
' <select class="input" name="rescale_redraw" title="Redraw the timeline while adjusting the timescale.">'+
' <option value="true" '+(rescale_redraw ? 'selected' : '')+'>Yes</option>'+
' <option value="false" '+(rescale_redraw ? '' : 'selected')+'>No</option>'+
' </select>'+
' <label title="Show rad+kan+voc bars, or summary only.">Bar Style</label>'+
' <select class="input" name="bar_style" title="Show rad+kan+voc bars, or summary only.">'+
' <option value="false" '+(summary_bar_only ? '' : 'selected')+'>Rad+Kan+Voc</option>'+
' <option value="true" '+(summary_bar_only ? 'selected' : '')+'>Summary only</option>'+
' </select>'+
' <label title="Show highlight bar on graph for special reviews.">Special Review Bars</label>'+
' <select class="input" name="show_bars" title="Show highlight bar on graph for special reviews.">'+
' <option value="0" '+(!show_current_bars && !show_burn_bars ? 'selected' : '')+'>None</option>'+
' <option value="1" '+(show_current_bars && !show_burn_bars ? 'selected' : '')+'>Current Level</option>'+
' <option value="2" '+(!show_current_bars && show_burn_bars ? 'selected' : '')+'>Burn Items</option>'+
' <option value="3" '+(show_current_bars && show_burn_bars ? 'selected' : '')+'>Both</option>'+
' </select>'+
' <label title="Show \'current level\' indicators below graph."><i>Current Level</i> Markers</label>'+
' <select class="input" name="mark_current" title="Show \'current level\' indicators below graph.">'+
' <option value="0" '+(!mark_current ? 'selected' : '')+'>None</option>'+
' <option value="1" '+(mark_current && !mark_current_vocab ? 'selected' : '')+'>Rad+Kan</option>'+
' <option value="2" '+(mark_current && mark_current_vocab ? 'selected' : '')+'>Rad+Kan+Voc</option>'+
' </select>'+
' <label title="Height of the graph, in pixels.">Graph Height (in pixels)</label>'+
' <input class="input" name="graph_height" type="number" title="Height of the graph, in pixels." value="'+graph_height+'">'+
' <label title="Slider range max, in days.">Slider Range Max (days)</label>'+
' <input class="input" name="max_days" type="number" title="Slider range max, in days." value="'+max_days+'">'+
' <label title="Where the timeline will be placed on the dashboard.">Timeline Location</label>'+
' <select class="input" name="placement" title="Where the timeline will be placed on the dashboard.">'+
' <option value="before_nextreview" '+(placement==='before_nextreview' ? 'selected' : '')+'>Before Next-Review</option>'+
' <option value="after_nextreview" '+(placement==='after_nextreview' ? 'selected' : '')+'>After Next-Review</option>'+
' <option value="after_srsprogress" '+(placement==='after_srsprogress' ? 'selected' : '')+'>After SRS-Progress</option>'+
' <option value="after_levelprogress" '+(placement==='after_levelprogress' ? 'selected' : '')+'>After Level-Progress</option>'+
' <option value="after_unlocks" '+(placement==='after_unlocks' ? 'selected' : '')+'>After New-Unlocks</option>'+
' <option value="after_recentchat" '+(placement==='after_recentchat' ? 'selected' : '')+'>After Recent-Chat</option>'+
' </select>'+
' <p class="buttons">'+
' <button type="button" value="1">Save</button>'+
' <button type="button" value="0">Cancel</button>'+
' </p>'+
' </form>'+
'</div>';
// 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 =
'<div id="timeln-help" class="dialog hidden">'+
' <h4>Timeline Help</h4>'+
' <p><b>WaniKani Ultimate Timeline</b> displays a schedule of your upcoming reviews.</p>'+
' <p class="new_section">'+
' <b>X-axis:</b> Time when reviews become available.<br />'+
' <b>Y-axis:</b> Number of reviews in a timeslot.<br />'+
' <b>Range slider:</b> Set the number of hours to display on the graph.</p>'+
' <p><b>Hover over a graph bar</b> to display a detail window, which shows details about the reviews in that timeslot.</p>'+
' <p><b>Click on a graph bar</b> to anchor the detail window, then hover over individual review items for individual item info.</p>'+
' <p><b>Click and drag along the top of the X-axis</b> to highlight a time range. The detail window will show details about all reviews in that time range.</p>'+
' <p><b>Current level reviews</b> are indicated by a white arrow below the timeslot, a white background behind the timeslot, and a yellow &quot;Current Level&quot; box in the detail window.<br />'+
' <b>Burn reviews</b> are indicated by a black arrow below the timeslot, a black background behind the timeslot, and a black &quot;Burn Items&quot; box in the detail window.</p>'+
' <p class="new_section">'+
' <b>Graph updates</b> occur automatically every 15 minutes, and the timescale slowly moves to the left.'+
' As time passes, your available reviews will accumulate in the left-most timeslot, which represents &quot;now&quot;.</p>'+
' <p><b>Forced refresh</b> is like clearing your browser cache. It is usually only needed if you do reviews on a different device or computer.'+
' Normally, you only need to return to the WaniKani dashboard after doing reviews, and the timeline will fetch your updated schedule.</p>'+
' <p class="new_section">'+
' Contact: Robin Findley (rfindley@usa.net)</p>'+
' <p class="buttons"><button type="button">Ok</button></p>'+
'</div>';
// 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 = $('<div id="timeln-modal"></div>');
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.currentTarget);
switch (e.type) {
//-----------------------------
case 'mouseenter':
var item;
var type = target.data('type');
var str = '<div class="'+type+'">';
switch (type) {
case 'rad':
item = graph_detail_items.radicals[target.data('idx')];
str += '<span class="item">Item: <span lang="ja">';
if (item.character !== null)
str += item.character+'</span></span><br />';
else
str += '<i class="radical-'+item.meaning+'"></i></span></span><br />';
str += 'Meaning: '+toTitleCase(item.meaning)+'<br />';
str += 'Level: '+item.level+'<br />';
str += 'SRS Level: '+srslvls[item.user_specific.srs_numeric-1]+'<br />';
break;
case 'kan':
item = graph_detail_items.kanji[target.data('idx')];
str += '<span class="item">Item: <span lang="ja">'+item.character+'</span></span><br />';
str += toTitleCase(item.important_reading)+': <span lang="ja">'+item[item.important_reading]+'</span><br />';
str += 'Meaning: '+toTitleCase(item.meaning)+'<br />';
if (item.user_specific.user_synonyms !== null && item.user_specific.user_synonyms.length > 0)
str += 'Synonyms: '+toTitleCase(item.user_specific.user_synonyms.join(', '))+'<br />';
str += 'Level: '+item.level+'<br />';
str += 'SRS Level: '+srslvls[item.user_specific.srs_numeric-1]+'<br />';
break;
case 'voc':
item = graph_detail_items.vocabulary[target.data('idx')];
str += '<span class="item">Item: <span lang="ja">'+item.character+'</span></span><br />';
str += 'Reading: <span lang="ja">'+item.kana+'</span><br />';
str += 'Meaning: '+toTitleCase(item.meaning)+'<br />';
if (item.user_specific.user_synonyms !== null && item.user_specific.user_synonyms.length > 0)
str += 'Synonyms: '+toTitleCase(item.user_specific.user_synonyms.join(', '))+'<br />';
str += 'Level: '+item.level+'<br />';
str += 'SRS Level: '+srslvls[item.user_specific.srs_numeric-1]+'<br />';
break;
}
str += '</div>';
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 += '<div class="summary">';
str += '<div class="tot">'+(slot_sum.radicals.length+slot_sum.kanji.length+slot_sum.vocabulary.length)+' reviews</div>';
str += '<div class="indent">';
str += '<div class="rad">'+slot_sum.radicals.length+' radicals</div>';
str += '<div class="kan">'+slot_sum.kanji.length+' kanji</div>';
str += '<div class="voc">'+slot_sum.vocabulary.length+' vocabulary</div>';
str += '</div>';
if (slot_sum.has_current) str += '<div class="cur">Current Level</div>';
if (slot_sum.has_burn) str += '<div class="bur">Burn Items</div>';
str += '</div>';
// 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 += '<ul class="detail">';
for (idx = 0; idx < slot_sum.radicals.length; idx++) {
item = slot_sum.radicals[idx];
str += '<li class="rad" lang="ja" data-type="rad" data-idx="'+idx+'">';
if (item.character !== null)
str += item.character+'</li>';
else
str += '<i class="radical-'+item.meaning+'"></i></li>';
}
for (idx = 0; idx < slot_sum.kanji.length; idx++) {
item = slot_sum.kanji[idx];
str += '<li class="kan" lang="ja" data-type="kan" data-idx="'+idx+'">'+item.character+'</li>';
}
for (idx = 0; idx < slot_sum.vocabulary.length; idx++) {
item = slot_sum.vocabulary[idx];
str += '<li class="voc" lang="ja" data-type="voc" data-idx="'+idx+'">'+item.character+'</li>';
}
str += '</ul>';
}
// 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() {
srs_styles=["app1","app2","app3","app4","guru1","guru2","master","enlightened"];
// 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 += '<polyline class="'+tic_class+'" points="'+graph_width_left+','+y+' '+(graph_width_panel-1)+','+y+'" />';
label_y += '<text class="'+tic_class+'" x="'+(graph_width_left-4)+'" y="'+y+'" dy="0.4em">'+tic+'</text>';
}
// 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 += '<polyline class="'+tic_class+'" points="'+x+',0 '+x+','+(graph_height_top+graph_height-1)+'" />';
label_x += '<text class="'+tic_class+'" x="'+(x+4)+'" y="'+(graph_height_top-8)+'">'+label+'</text>';
} else if (qh % minor_tic === 0) {
// Minor Tic mark
if (tic > 0)
grid += '<polyline class="minor" points="'+x+','+(graph_height_top-6)+' '+x+','+(graph_height_top+graph_height-1)+'" />';
}
// 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 += '<rect class="sum" x="'+x1+'" y="0" width="'+(x2-x1)+'" height="'+(rad+kan+voc)+'" />';
base += rad+kan+voc;
} else if (get_setting('show_srs_timeline')) {
slot.srs_levels.forEach(function(lvl,idx){
if (lvl > 0) {
bars += '<rect class="'+srs_styles[idx]+'" x="'+x1+'" y="'+base+'" width="'+(x2-x1)+'" height="'+lvl+'" />';
base += lvl;
}
});
} else {
if (rad > 0) {
bars += '<rect class="rad" x="'+x1+'" y="0" width="'+(x2-x1)+'" height="'+rad+'" />';
base += rad;
}
if (kan > 0) {
bars += '<rect class="kan" x="'+x1+'" y="'+base+'" width="'+(x2-x1)+'" height="'+kan+'" />';
base += kan;
}
if (voc > 0) {
bars += '<rect class="voc" x="'+x1+'" y="'+base+'" width="'+(x2-x1)+'" height="'+voc+'" />';
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 += '<rect class="cur" x="'+x1+'" y="'+base+'" width="'+(x2-x1)+'" height="'+(graph_reviews-base)+'" />';
arrows += '<polygon class="cur" points="'+ac+','+ay+' '+ax1+','+(ay+ah)+' '+ax2+','+(ay+ah)+'" />';
}
ay += ah+1;
if (slot.has_burn) {
if (get_setting('show_burn_bars') && !(slot.has_current && get_setting('show_current_bars')))
bars += '<rect class="bur" x="'+x1+'" y="'+base+'" width="'+(x2-x1)+'" height="'+(graph_reviews-base)+'" />';
arrows += '<polygon class="bur" points="'+ac+','+ay+' '+ax1+','+(ay+ah)+' '+ax2+','+(ay+ah)+'" />';
}
bars += '<rect class="clr" x="'+x1+'" y="0" width="'+(x2-x1)+'" height="'+graph_reviews+'" data-slot="'+tic+'" />';
}
}
// Build the html/svg object from the components build above.
timeln_graph.append(
'<svg id="timeline" class="noselect" width="'+graph_width_panel+'" height="'+graph_height_panel+'">'+
' <g class="grid" transform="translate(0.5,0.5)">'+
grid+
' <polyline class="shadow" points="'+(graph_width_left-2)+',0 '+(graph_width_left-2)+','+(graph_height_top+graph_height-1)+'" />'+
' <polyline class="light" points="'+(graph_width_left-1)+',0 '+(graph_width_left-1)+','+(graph_height_top+graph_height-1)+'" />'+
' <polyline class="light" points="'+(graph_width_left-2)+','+(graph_height_top+graph_height+1)+' '+(graph_width_panel-1)+','+(graph_height_top+graph_height+1)+'" />'+
' <polyline class="shadow" points="'+(graph_width_left-2)+','+(graph_height_top+graph_height)+' '+(graph_width_panel-1)+','+(graph_height_top+graph_height)+'" />'+
' </g>'+
' <g class="label-x">'+
label_x+
' </g>'+
' <g class="label-y">'+
label_y+
' </g>'+
' <g class="arrows">'+
arrows+
' </g>'+
' <svg x="'+graph_width_left+'" y="'+graph_height_top+'" width="'+graph_width+'" height="'+graph_height+'" viewbox="0 0 '+graph_width+' '+graph_reviews+'" preserveAspectRatio="none">'+
' <g class="bars" transform="scale(1,-1) translate(0,-'+graph_reviews+')">'+
bars+
' </g>'+
' </svg>'+
' <g class="hilight">'+ //RJF
' <rect x="0" y="-1000" height="'+graph_height+'" transform="translate(0.5,0.5)" shape-rendering="crispEdges" />'+
' <path class="marker" transform="translate(0 -1000)" d="M 0 0 L -3 -5 L 3 -5 L 0 0 L 0 '+(graph_height+1)+'" />'+
' <path class="marker" transform="translate(0 -1000)" d="M 0 0 L -3 -5 L 3 -5 L 0 0 L 0 '+(graph_height+1)+'" />'+
' </g>'+
'</svg>'
);
// 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, srs_levels:new Array(8).fill(0)};
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);
slot.srs_levels[item.user_specific.srs_numeric-1]++;
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;
console.log(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 <style> section to the document.
//-------------------------------------------------------------------
function addStyle(aCss) {
var head, style;
head = document.getElementsByTagName('head')[0];
if (head) {
style = document.createElement('style');
style.setAttribute('type', 'text/css');
style.textContent = aCss;
head.appendChild(style);
return style;
}
return null;
}
//-------------------------------------------------------------------
// Fetch a document from the server.
//-------------------------------------------------------------------
function ajax_retry(url, retries, timeout) {
retries = retries || 3;
timeout = timeout || 3000;
function action(resolve, reject) {
$.ajax({
url: url,
timeout: timeout
})
.done(function(data, status){
if (status === 'success')
resolve(data);
else
reject();
})
.fail(function(xhr, status, error){
if ((status === 'error' || status === 'timeout') && --retries > 0)
action(resolve, reject);
else
reject();
});
}
return new Promise(action);
}
//-------------------------------------------------------------------
// Fetch API key from account page.
//-------------------------------------------------------------------
function get_api_key() {
return new Promise(function(resolve, reject) {
api_key = localStorage.getItem('apiKey');
if (typeof api_key === 'string' && api_key.length == 32) return resolve();
status_div.html('Fetching API key...');
ajax_retry('/settings/account').then(function(page) {
// --[ SUCCESS ]----------------------
// Make sure what we got is a web page.
if (typeof page !== 'string') {return reject();}
// Extract the user name.
page = $(page);
// Extract the API key.
api_key = page.find('#user_api_key').attr('value');
if (typeof api_key !== 'string' || api_key.length !== 32) {
return reject(new Error('generate_apikey'));
}
localStorage.setItem('apiKey', api_key);
resolve();
},function(result) {
// --[ FAIL ]-------------------------
reject(new Error('Failed to fetch API key!'));
});
});
}
//-------------------------------------------------------------------
// Slider conversion functions.
//-------------------------------------------------------------------
function slider_value(hours) {
return (Number(hours)/24).toFixed(2);
}
function slider_label(hours) {
if (hours <= 24)
return hours+' hours';
else
return (Number(hours)/24).toFixed(2) + ' days';
}
//-------------------------------------------------------------------
// Fetch review data.
//-------------------------------------------------------------------
function get_review_data() {
return new Promise(function(resolve, reject) {
status_div.html(
'Loading review data. Please be patient...<br>'+
'Radicals: <span class="radicals">0%</span><br>'+
'Kanji: <span class="kanji">0%</span><br>'+
'Vocabulary: <span class="vocabulary">0%</span>'
);
user_data = {radicals:[], kanji:[], vocabulary:[]};
var cnt = {radicals:0, kanji:0, vocabulary:0};
var max = Math.ceil(user_level/levels_per_fetch);
var max_date = next_review + (Math.round(Number(get_setting('max_days'))*24)+1)*3600;
function fetch(type,lvl,delay) {
setTimeout(function(){
$.getJSON('/api/user/'+api_key+'/'+type+'/'+lvl)
.then(function(json){
if (json.error && json.error.code === 'user_not_found') {
localStorage.removeItem('apiKey');
localStorage.removeItem('timeln_last_fetch');
location.reload();
reject();
return;
}
$(json.requested_information).each(function(i,v){
try {
if (v.user_specific.burned === false && v.user_specific.available_date < max_date)
user_data[type].push(v);
} catch(e) {}
});
cnt[type]++;
status_div.find('.'+type).html(''+Math.round(100*cnt[type]/max)+'%');
if (cnt.radicals == max && cnt.kanji == max && cnt.vocabulary == max) {
last_fetch = Math.floor(new Date()/1000);
localStorage.setItem('timeln_last_fetch', last_fetch);
localStorage.setItem('timeln_cache', JSON.stringify(user_data));
resolve();
}
})
.fail(function(data){
if (data.status === 403 && data.responseText === 'Rate Limit Exceeded') {
fetch(type, lvl, 500);
} else {
console.log('Error '+data.status+': '+data.responseText);
}
});
}, delay);
}
var delay = 0;
var arr = [];
for (var lvl=1; lvl<=user_level; lvl++) {
arr.push(lvl);
if (((lvl % levels_per_fetch) === 0) || (lvl == user_level)) {
var str = arr.join(',');
fetch('radicals',str, delay);
delay += ms_betw_fetches;
fetch('kanji',str, delay);
delay += ms_betw_fetches;
fetch('vocabulary',str, delay);
delay += ms_betw_fetches;
arr = [];
}
}
});
}
//-------------------------------------------------------------------
// Place the timeline on the dashboard.
//-------------------------------------------------------------------
function place_timeline(html) {
if (html === undefined) html = $('#timeln');
switch (get_setting('placement')) {
case 'after_nextreview' : $('section.review-status').after(html); break;
case 'after_srsprogress' : $('section.srs-progress').after(html); break;
case 'after_levelprogress': $('section.progression').after(html); break;
case 'after_unlocks' : $('section.recent-unlocks').closest('div.row').after(html); break;
case 'after_recentchat' : $('section.forum-topics-list').closest('div.row').after(html); break;
default: $('section.review-status').before(html); break; // 'before_nextreview'
}
}
//-------------------------------------------------------------------
// Startup. Runs at document 'load' event.
//-------------------------------------------------------------------
function startup(warmboot) {
var now = Math.floor(new Date()/1000);
// If we are on the account screen, check if user requested
// to automatically generate an API key. Otherwise, do nothing.
if (window.location.pathname == '/settings/account') {
var status = localStorage.getItem('timeln_generate_apikey');
if (status == 'generate') {
localStorage.setItem('timeln_generate_apikey','refresh');
$('#user_api_key').closest('fieldset').find('button').click();
return;
}
if (status === 'refresh') {
api_key = $('#user_api_key').attr('value');
if (typeof api_key === 'string' && api_key.length === 32)
localStorage.setItem('apiKey', api_key);
localStorage.removeItem('timeln_generate_apikey');
window.location.href = '/dashboard';
}
return;
}
// If we are on the review screen, record the time, so we know our
// cache is out of date when we return to dashboard.
if (window.location.pathname == '/review/session' || window.location.pathname == '/lesson/session') {
localStorage.setItem('timeln_last_review', now);
return;
}
// Clear cache if different user is logged in.
var last_user = localStorage.getItem('timeln_username') || '';
var current_user = $('.account a[href^="/users/"]').attr('href').split('/').pop();
if (current_user != last_user) clear_cache();
localStorage.setItem('timeln_username', current_user);
// Load settings.
if (localStorage.getItem('timeln_settings'))
$.extend(true, settings, JSON.parse(localStorage.getItem('timeln_settings')));
gobj.settings = settings;
graph_hours = localStorage.getItem('timeln_graph_hours');
if (!graph_hours) graph_hours = 36;
// Some DOM setup that we don't want to repeat if user forced refresh (i.e. 'warm boot').
if (warmboot !== true) {
addStyle(css);
var jp_font = get_setting('jp_font');
place_timeline(
'<section id="timeln">'+
' <style id="timeln-style">#timeln [lang="ja"] {font-family:'+jp_font+';}</style>'+
' <h4 class="no_min">Reviews Timeline</h4>'+
' <i id="timeln-open" class="link no_min icon-chevron-down" title="Open the timeline"></i>'+
' <i id="timeln-minimize" class="link icon-chevron-up" title="Minimize the timeline"></i>'+
' <i id="timeln-refresh-lnk" class="link icon-refresh" title="Force full data refresh. Usually not necessary. Please use sparingly."></i>'+
' <i id="timeln-settings-lnk" class="link icon-gear" title="Change timeline settings"></i>'+
' <i id="timeln-help-lnk" class="link icon-question" title="Show instructions"></i>'+
' <form id="range_form" class="hidden"><label><span id="range_reviews">0</span> reviews in <span id="range_days">'+slider_label(graph_hours)+'</span> <input id="range_input" type="range" min="0.25" max="'+slider_value(get_setting('max_days')*24)+'" value="'+slider_value(graph_hours)+'" step="0.25" name="range_input"></label></form><br clear="all" class="no_min">'+
' <div id="graph-bar-info" class="hidden"></div>'+
' <div id="graph-item-info" class="hidden"></div>'+
' <div id="timeln-graph"><div id="timeln-status" class="hidden"></div></div>'+
'</section>'
);
$('#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);
} 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)
.catch(function(e){
if (e.message === 'generate_apikey') {
status_div.html(
'It seems you haven\'t generated an API key yet.<br>'+
'Please click [<a id="timeln_gen_apikey" href="/settings/account" rel="nofollow">here</a>] to generate one automatically.'
);
$('#timeln_gen_apikey').on('click',function(){
localStorage.setItem('timeln_generate_apikey','generate');
});
} else {
status_div.html(e.message);
}
});
}
// Run startup() after window.onload event.
if (document.readyState === 'complete')
startup();
else
window.addEventListener("load", startup, false);
})(wktimeln);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment