';
// Add the dialog to the DOM, and display it.
dialog = $(str);
$('#timeln').append(dialog);
var t = $('#timeln').position();
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 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");
draw_timeline();
} else {
var jp_font = get_setting('jp_font');
console.log('Reverting font back to "'+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="jp"] {font-family:'+jp_font+';}');
$('#timeln-settings .input[name="show_detail"]').val(get_setting('show_detail').toString());
}
});
// 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="jp"] {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() {
$('#timeln-settings').addClass('hidden');
var dialog = $('#timeln-help');
// If settings dialog already exists, show it and exit.
if (dialog.length > 0) {
dialog.removeClass('hidden');
return;
}
// The dialog doesn't exist. Create it.
str =
'
'+
'
Timeline Help
'+
'
WaniKani Ultimate Timeline displays a schedule of your upcoming reviews.
'+
'
'+
' X-axis: Time when reviews become available. '+
' Y-axis: Number of reviews in a timeslot. '+
' Range slider: Set the number of hours to display on the graph (up to '+max_hours+' hours).
'+
'
Hover over a graph bar to display a detail window, which shows details about the reviews in that timeslot.
'+
'
Click on a graph bar to anchor the detail window, then hover over individual review items for individual item info.
'+
'
Click or drag along the top of the X-axis to highlight a time range. The detail window will show details about all reviews in that time range.
'+
'
Current level reviews 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. '+
' Burn reviews 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.
'+
'
'+
' Graph updates 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".
'+
'
Forced refresh 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.
'+
'
'+
' Contact: Robin Findley (rfindley@usa.net)
'+
' '+
'
';
dialog = $(str);
$('#timeln').append(dialog);
var t = $('#timeln').position();
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_cache');
localStorage.removeItem('timeln_last_fetch');
localStorage.removeItem('timeln_last_review');
}
//-------------------------------------------------------------------
// Retrieve the value of a setting.
//-------------------------------------------------------------------
function get_setting(name) {
return settings[name];
}
//-------------------------------------------------------------------
// Close the modal window.
//-------------------------------------------------------------------
function close_modal() {
$('#timeln-modal').remove();
}
//-------------------------------------------------------------------
// Set up a full-screen modal window at z-index-10 to catch events.
//-------------------------------------------------------------------
function open_modal(events, handler) {
var modal = $('');
modal.height($(document).height());
$('body').prepend(modal);
modal.on(events, handler);
}
//-------------------------------------------------------------------
// Event handler for item details.
//-------------------------------------------------------------------
function item_info_event(e) {
var hinfo = $('#graph-item-info');
var target = $(e.target);
switch (e.type) {
//-----------------------------
case 'mouseenter':
var item;
var type = target.data('type');
var str = '
';
}
hinfo.css('max-width', $('#timeln-graph').width()/2 - 15);
hinfo.html(str);
if (show_detail) {
$('#timeln .detail').on('mouseenter', 'li', item_info_event);
$('#timeln .detail').on('mouseleave', item_info_event);
}
if (graph_hilight_mode != 0) {
var tlpos = $('#timeline').position();
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 + graph_height_panel);
}
hinfo.removeClass('hidden');
}
//-------------------------------------------------------------------
// Event handler for time slots.
//-------------------------------------------------------------------
function bar_events(e) {
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':
// 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.
if (slot_idx < graph_hours*2)
hinfo.css('left', Math.floor(target.position().left + e.target.width.baseVal.value)+3);
else
hinfo.css('left', Math.floor(target.position().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':
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:
graph_hilight_mode++;
// Fall-through
//-----------------------------
case 1:
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:
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)));
var tlpos = $('#timeline').position();
populate_info(graph_range_slot1, graph_range_slot2, !show_detail_while_dragging);
break;
}
break;
//-----------------------------
case 'mousedown':
if (e.button != 0) break;
switch (graph_hilight_mode) {
case 1:
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:
$('#timeln-modal').css('z-index',1);
graph_hilight_mode = 3;
populate_info(graph_range_slot1, graph_range_slot2, false);
break;
case 3:
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;
if (graph_hilight_mode != 2) break;
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;
// m1.attr('transform','translate(0 -1000)');
m2.attr('transform','translate(0 -1000)');
mr.attr('y','-1000');
hinfo.addClass('hidden');
close_modal();
}
break;
//-----------------------------
case 'mouseleave':
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 = Number($('#range_input').val());
localStorage.setItem('timeln_graph_hours', graph_hours);
$('#range_hours').text(graph_hours);
if (e.type === 'change')
draw_timeline();
}
//-------------------------------------------------------------------
// Draw the timeline.
//-------------------------------------------------------------------
function draw_timeline() {
// 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();
// Update slider label with number of reviews on graph.
$('#range_reviews').text(graph_review_total);
// Calculate graph dimensions.
var timeln_graph = $('#timeln-graph');
$('#timeline').remove();
graph_height_panel = timeln_graph.height();
graph_height = graph_height_panel - (graph_height_top + graph_height_bottom);
graph_width_panel = timeln_graph.width();
graph_width = graph_width_panel - graph_width_left;
// String for building html.
var grid = '';
var label_x = '';
var label_y = '';
var bars = '';
var arrows = '';
// Calculate major and minor vertical graph tics.
var inc_s = 1, inc_l = 5;
while (Math.ceil(max_reviews / inc_s) > 5) {
switch (inc_s.toString()[0]) {
case '1': inc_s *= 2; inc_l *= 2; break;
case '2': inc_s = Math.round(2.5 * inc_s); break;
case '5': inc_s *= 2; inc_l *= 5; break;
}
}
// Draw vertical graph tics (# of Reviews).
var tic, tic_class, y;
graph_reviews = Math.ceil(max_reviews / inc_s) * inc_s;
for (tic = 0; tic <= graph_reviews; tic += inc_s) {
tic_class = ((tic % inc_l) === 0 ? 'major' : 'minor');
y = (graph_height_top + graph_height) - Math.round(graph_height * (tic / graph_reviews));
if (tic > 0)
grid += '';
label_y += ''+tic+'';
}
// Draw a horizontal red tic for max reviews.
// y = (graph_height_top + graph_height) - Math.round(graph_height * (max_reviews / graph_reviews));
// grid += '';
// Set up to draw horizontal graph tics (Time).
var major_tic_choices = [1, 3, 6, 12, 24]; // Hours
var minor_tic_choices = [1, 4, 4, 12, 24]; // 15min intervals
var max_labels = Math.floor(graph_width / 50); // No more than 1 label every 50 pixels
var tic_choice = 0;
while ((graph_hours / major_tic_choices[tic_choice]) > max_labels && tic_choice < major_tic_choices.length) tic_choice++;
var major_tic = major_tic_choices[tic_choice] * 4;
var minor_tic = minor_tic_choices[tic_choice];
// Draw grid tics, and populate datapoints
var tic_ofs = Math.floor((calc_time - (new Date(calc_time)).setHours(0, 0, 0, 0)) / 900000);
graph_width_bar = (graph_width-1) / (graph_hours*4); // Width of a time slot.
for (tic = 0; tic <= graph_hours*4; tic++) {
var x = Math.floor(graph_width_left + tic * graph_width_bar);
// Need to use date function to account for time shifts (e.g. Daylight Savings Time)
tstamp = new Date(calc_time + tic * 900000);
var hh = tstamp.getHours();
var qh = hh*4 + Math.round(tstamp.getMinutes()/15);
// Check if we are on a Major Tic mark
if (qh % major_tic === 0) {
// Start of a new day?
if (hh === 0) {
tic_class = 'newday';
label = 'SunMonTueWedThuFriSat'.substr(tstamp.getDay()*3, 3);
} else {
tic_class = 'major';
tstamp = new Date(calc_time + tic * 900000);
var hh = tstamp.getHours();
if (settings['24hour']) {
label = ('0'+hh+':00').slice(-5);
} else {
label = (((hh + 11) % 12) + 1) + 'ap'[Math.floor(hh/12)] + 'm';
}
}
if (tic > 0)
grid += '';
label_x += ''+label+'';
} else if (qh % minor_tic === 0) {
// Minor Tic mark
if (tic > 0)
grid += '';
}
// If there are reviews for the current timeslot, draw graph bars.
var slot = timeline[tic];
if (slot && tic < graph_hours*4) {
var x1 = x - graph_width_left;
var x2 = Math.floor((tic+1) * graph_width_bar);
var base = 0;
var rad = slot.radicals.length;
var kan = slot.kanji.length;
var voc = slot.vocabulary.length;
if (rad > 0) {
bars += '';
base += rad;
}
if (kan > 0) {
bars += '';
base += kan;
}
if (voc > 0) {
bars += '';
base += voc;
}
// If current timeslot has current-level items, or items ready for Burning, draw indicator arrows.
var ay = graph_height_top+graph_height+1;
var ac = graph_width_left+(x1+x2)/2;
var ah = 7;
var aw = Math.min(x2-x1, ah);
var ax1 = ac-(aw/2);
var ax2 = ax1+aw;
if (slot.has_current) {
bars += '';
arrows += '';
}
ay += ah+1;
if (slot.has_burn) {
if (!slot.has_current)
bars += '';
arrows += '';
}
bars += '';
}
}
// Build the html/svg object from the components build above.
timeln_graph.append(
''
);
// 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);
}
//-------------------------------------------------------------------
// 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'];
for (var type_idx in types) {
var type = types[type_idx];
var item_cnt = user_data[type].length;
for (var item_idx = 0; item_idx < item_cnt; item_idx++) {
var item = user_data[type][item_idx];
var item_time = Math.floor(item.user_specific.available_date / 900); // Round down to 15min.
var slot_idx = Math.min(max_slot, Math.max(0, item_time - next_time));
if (timeline[slot_idx] === undefined)
timeline[slot_idx] = {radicals:[], kanji:[], vocabulary:[], item_count:0, has_current:false, has_burn:false, item_time:item_time*900};
var slot = timeline[slot_idx];
slot.item_count++;
if (slot_idx < max_slot) {
graph_review_total++;
if (slot.item_count > max_reviews)
max_reviews = slot.item_count;
}
slot[type].push(item);
if (item.level == user_level)
slot.has_current = true;
if (item.user_specific.srs === 'enlightened')
slot.has_burn = true;
}
}
gobj.timeline = timeline;
}
//-------------------------------------------------------------------
// Make first letter of each word upper-case.
//-------------------------------------------------------------------
function toTitleCase(str) {
return str.replace(/\w\S*/g, function(txt){return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase();});
}
//-------------------------------------------------------------------
// Add a '+
'