Created
February 15, 2018 05:17
-
-
Save Atonement100/01ac1ad56ab46b828120ddda898a1007 to your computer and use it in GitHub Desktop.
wkultimate srs timeline
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==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 "Current Level" 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 "Burn Items" 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 "now".</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