您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Review schedule explorer for WaniKani
当前为
// ==UserScript== // @name WaniKani Ultimate Timeline // @namespace rfindley // @description Review schedule explorer for WaniKani // @version 6.0.9 // @include https://www.wanikani.com/ // @include https://www.wanikani.com/dashboard // @include https://www.wanikani.com/review/session* // @copyright 2015+, Robin Findley // @license MIT; http://opensource.org/licenses/MIT // @run-at document-end // @grant none // ==/UserScript== wktimeln = {}; (function(gobj) { var settings = { '24hour': false, 'jp_font': 'Meiryo', 'show_detail': true, 'rescale_redraw': true, 'mark_current_vocab': 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 max_hours = 168, levels_per_fetch = 5, total_graph_height = 150, graph_width_left = 28, graph_height_top = 16, graph_height_bottom = 16, max_reviews, graph_hours, graph_reviews = 0, graph_review_total = 0, graph_height_panel, graph_height, graph_width_panel, graph_width, graph_width_bar, graph_hilight_x1, graph_hilight_x2, graph_hilight_mode = 0, graph_range_slot1, graph_range_slot2, graph_detail_items, show_detail_while_dragging = true, api_key, user_level = 1, user_data, timeline, status_div, calc_time, current_slot, detail_latched = false, next_review, last_review, last_unlock, last_fetch, first_draw = true; var srslvls = ['Apprentice 1','Apprentice 2','Apprentice 3','Apprentice 4','Guru 1','Guru 2','Master','Enlightened','Burned']; var css = '#graph-bar-info {'+ ' position: absolute;'+ ' padding: 4px 8px 8px 8px;'+ ' color: #eeeeee;'+ ' background-color: rgba(0,0,0,0.8);'+ ' border-radius: 4px;'+ ' font-weight: bold;'+ ' z-index:2;'+ '}'+ '#graph-bar-info .summary {font-family:"Open Sans","Helvetica Neue",Helvetica,Arial,sans-serif; font-size:13px; max-width:140px;}'+ '#graph-bar-info .summary div {padding:0px 8px;}'+ '#graph-bar-info .summary .indent {padding:0;}'+ '#graph-bar-info .summary .indent div {padding-left:16px;}'+ '#graph-bar-info .summary .tot {color:#000000; background-color:#efefef; background-image:linear-gradient(to bottom, #efefef, #cfcfcf);}'+ '#graph-bar-info .rad {background-color:#0096e7; background-image:linear-gradient(to bottom, #0af, #0093dd);}'+ '#graph-bar-info .kan {background-color:#ee00a1; background-image:linear-gradient(to bottom, #f0a, #dd0093);}'+ '#graph-bar-info .voc {background-color:#9800e8; background-image:linear-gradient(to bottom, #a0f, #9300dd); margin-bottom:8px;}'+ '#graph-bar-info .summary .cur {text-align:center; font-style:italic; color:#000000; background-color:#ffff88; background-image:linear-gradient(to bottom, #ffffaa, #eeee77);}'+ '#graph-bar-info .summary .bur {text-align:center; font-style:italic; color:#ffffff; background-color:#000000; background-image:linear-gradient(to bottom, #444444, #000000);}'+ '#graph-bar-info .detail {margin: 8px 0 0 0; padding: 0px;}'+ '#graph-bar-info .detail li {padding:0 3px; margin:1px 1px; display:inline-block; cursor:pointer; border-radius:4px; font-size:14px;}'+ '#graph-bar-info .detail li img {height:0.8em; width:0.8em;}'+ 'div#graph-item-info {'+ ' position: absolute;'+ ' padding:8px;'+ ' color: #eeeeee;'+ ' background-color:rgba(0,0,0,0.8);'+ ' border-radius:8px;'+ ' font-family:"Open Sans","Helvetica Neue",Helvetica,Arial,sans-serif;'+ ' font-weight: bold;'+ ' z-index:3;'+ '}'+ '#graph-item-info .item {font-size:2em; line-height:1.2em;}'+ '#graph-item-info .item img {height:1em; width:1em; vertical-align:bottom;}'+ '#graph-item-info>div {padding:0 8px; background-color:#333333;}'+ 'section#timeln {margin-bottom: 0px; border-bottom: 1px solid #d4d4d4;}'+ '#timeln-graph {height:116px;}'+ '#timeln-graph div, #timeln-graph canvas {height:100%;width:100%;}'+ '#timeln-graph div {border:1px solid #d4d4d4;}'+ 'form#range_form {'+ ' float: right;'+ ' margin-bottom: 0px;'+ ' min-width: 50%;'+ ' text-align: right;'+ '}'+ 'section#timeln h4 {'+ ' clear: none;'+ ' float: left;'+ ' height: 20px;'+ ' margin-top: 0px;'+ ' margin-bottom: 4px;'+ ' font-weight: normal;'+ ' margin-right: 12px;'+ '}'+ '@media (max-width: 767px) {section#timeln h4 {display: none;}}'+ '.dashboard section.review-status {border-top: 1px solid #ffffff;}'+ '.dashboard section.review-status ul li time {white-space: nowrap; overflow-x: hidden; height: 1.5em; margin-bottom: 0;}'+ '#timeline {overflow:hidden;}'+ '#timeline .grid {pointer-events:none;}'+ '#timeline .grid polyline {fill:none;stroke:black;stroke-linecap:square;shape-rendering:crispEdges;}'+ '#timeline .grid .light {stroke:#ffffff;}'+ '#timeline .grid .shadow {stroke:#d5d5d5;}'+ '#timeline .grid .major {opacity:0.15;}'+ '#timeline .grid .minor {opacity:0.05;}'+ '#timeline .grid .newday {stroke:#f22;opacity:1;}'+ '#timeline .grid .max {stroke:#f22;opacity:0.2;}'+ '#timeline text.newday {fill:#f22;font-weight:bold;}'+ '#timeline .label-x text {text-anchor:start;font-size:0.8em;}'+ '#timeline .label-y text {text-anchor:end;font-size:0.8em;}'+ '.noselect {-webkit-touch-callout:none; -webkit-user-select:none; -khtml-user-select:none; -moz-user-select:none; -ms-user-select:none; user-select:none; cursor:default;}'+ '#timeline text {pointer-events:none;}'+ '#timeline .bars rect {stroke:none;shape-rendering:crispEdges;}'+ '#timeline .rad {fill:#00a1f1;}'+ '#timeline .kan {fill:#f100a1;}'+ '#timeline .voc {fill:#a100f1;}'+ '#timeline .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 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:0.8em; font-weight:bold; text-decoration:none; cursor:pointer;}'+ '#timeln .link:hover {color:rgba(255,31,31,0.5);}'+ '#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:40%;'+ ' 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-settings .input {vertical-align:baseline;}'+ '#timeln .dialog .buttons {text-align:center; margin-bottom:4px; background-color:#222; border-radius:4px;}'+ '#timeln-settings form {margin-bottom:0;}'+ '#timeln-settings {width:450px;}'+ '#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 dialog = $('#timeln-settings'); // If settings dialog already exists, show it and exit. if (dialog.length > 0) { dialog.removeClass('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 mark_current_vocab = (get_setting('mark_current_vocab') === true); 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="jp" 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 \'current level\' indicators for vocabulary.">Mark <i>Current</i> Vocab</label>'+ ' <select class="input" name="mark_current_vocab" title="Show \'current level\' indicators for vocabulary.">'+ ' <option value="true" '+(mark_current_vocab ? 'selected' : '')+'>Yes</option>'+ ' <option value="false" '+(mark_current_vocab ? '' : 'selected')+'>No</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); 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"); set_setting('rescale_redraw', $('#timeln-settings .input[name="rescale_redraw"]').val()=="true"); set_setting('mark_current_vocab', $('#timeln-settings .input[name="mark_current_vocab"]').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()); $('#timeln-settings .input[name="rescale_redraw"]').val(get_setting('rescale_redraw').toString()); $('#timeln-settings .input[name="mark_current_vocab"]').val(get_setting('mark_current_vocab').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() { // Hide any other open windows. $('#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 = '<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 (up to '+max_hours+' hours).</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 ([email protected])</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); 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 = $('<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.target); 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="jp">'; if (item.character !== null) str += item.character+'</span></span><br />'; else str += '<img src="'+item.image+'" /></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="jp">'+item.character+'</span></span><br />'; str += toTitleCase(item.important_reading)+': <span lang="jp">'+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="jp">'+item.character+'</span></span><br />'; str += 'Reading: <span lang="jp">'+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="jp" data-type="rad" data-idx="'+idx+'">'; if (item.character !== null) str += item.character+'</li>'; else str += '<img src="'+item.image+'" /></li>'; } for (idx = 0; idx < slot_sum.kanji.length; idx++) { item = slot_sum.kanji[idx]; str += '<li class="kan" lang="jp" 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="jp" 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').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) { // 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': // 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: // 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))); 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; // 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 = Number($('#range_input').val()); localStorage.setItem('timeln_graph_hours', graph_hours); $('#range_hours').text(graph_hours); if (e.type === 'change' || get_setting('rescale_redraw')) 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(); // 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 (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) { 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 (!slot.has_current) 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); } //------------------------------------------------------------------- // 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; var mark_current = (type != 'vocabulary' || get_setting('mark_current_vocab')); for (var item_idx = 0; item_idx < item_cnt; item_idx++) { var item = user_data[type][item_idx]; var item_time = Math.floor(item.user_specific.available_date / 900); // Round down to 15min. var slot_idx = Math.min(max_slot, Math.max(0, item_time - next_time)); if (timeline[slot_idx] === undefined) timeline[slot_idx] = {radicals:[], kanji:[], vocabulary:[], item_count:0, has_current:false, has_burn:false, item_time:item_time*900}; var slot = timeline[slot_idx]; slot.item_count++; if (slot_idx < max_slot) { graph_review_total++; if (slot.item_count > max_reviews) max_reviews = slot.item_count; } slot[type].push(item); if (mark_current && item.level == user_level) slot.has_current = true; if (item.user_specific.srs_numeric == 8) slot.has_burn = true; } } gobj.timeline = timeline; } //------------------------------------------------------------------- // Make first letter of each word upper-case. //------------------------------------------------------------------- function toTitleCase(str) { return str.replace(/\w\S*/g, function(txt){return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase();}); } //------------------------------------------------------------------- // Add a <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 || 2; 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' && --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('/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('#api-button').parent().find('input').attr('value'); if (typeof api_key !== 'string' || api_key.length !== 32) {return reject();} localStorage.setItem('apiKey', api_key); resolve(); },function(result) { // --[ FAIL ]------------------------- reject(new Error('Failed to fetch API key!')); }); }); } //------------------------------------------------------------------- // 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 + (max_hours+1)*3600; function fetch(type,lvl) { $.getJSON('/api/user/'+api_key+'/'+type+'/'+lvl,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(); } }); } 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); fetch('kanji',str); fetch('vocabulary',str); arr = []; } } }); } //------------------------------------------------------------------- // Startup. Runs at document 'load' event. //------------------------------------------------------------------- function startup(warmboot) { var now = Math.floor(new Date()/1000); // 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') { 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^="/community/people/"]').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'); $('section.review-status').before( '<section id="timeln">'+ ' <style id="timeln-style">#timeln [lang="jp"] {font-family:'+jp_font+';}</style>'+ ' <h4>Reviews Timeline</h4>'+ ' <a id="timeln-settings-lnk" class="link" title="Change timeline settings">[settings]</a>'+ ' <a id="timeln-refresh-lnk" class="link" title="Force full data refresh. Usually not necessary. Please use sparingly.">[force refresh]</a>'+ ' <a id="timeln-help-lnk" class="link" title="Show instructions">[help]</a>'+ ' <form id="range_form" class="hidden"><label><span id="range_reviews">0</span> reviews in <span id="range_hours">'+graph_hours+'</span> hours <input id="range_input" type="range" min="6" max="'+max_hours+'" value="'+graph_hours+'" step="6" name="range_input"></label></form><br clear="all">'+ ' <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-settings-lnk').on('click', click_settings); $('#timeln-refresh-lnk').on('click', click_refresh); $('#timeln-help-lnk').on('click', click_help); $('#timeln-graph').height(total_graph_height); } // Gather some info to help determine cache status. user_level = Number($('.levels span:nth(0)').text()); next_review = Number($('.review-status .timeago').attr('datetime')); last_review = Number(localStorage.getItem('timeln_last_review') || 0); last_unlock = new Date($('.recent-unlocks time:nth(0)').attr('datetime'))/1000; last_fetch = Number(localStorage.getItem('timeln_last_fetch') || 0); // Workaround for "WaniKani Real Times" script, which deletes the element we were looking for above. if (isNaN(next_review)) { next_review = Number($('.review-status time1').attr('datetime')); // Conditional divide-by-1000, in case someone fixed this error in Real Times script. if (next_review > 10000000000) next_review /= 1000; } // Fetch API key and update cache, only if necessary. var promise; status_div = $('#timeln-status'); if (last_fetch <= last_unlock || last_fetch <= last_review || (next_review < now && last_fetch <= (now-3600))) { status_div.removeClass('hidden'); status_div.html('Failed to fetch API key!'); promise = get_api_key() .then(get_review_data) .catch(function(e){status_div.html(e.message);}); } else { promise = Promise.resolve(); } // We have an up-to-date cache. Draw the timeline. promise.then(function(){ // Fetch user data from cache. user_data = JSON.parse(localStorage.getItem('timeln_cache')); // Draw the timeline. status_div.addClass('hidden'); draw_timeline(); // Install event handlers for time range slider. $('#range_input').on('input change', change_hours); $('#range_form').removeClass('hidden'); },null); } // Run startup() after window.onload event. if (document.readyState === 'complete') startup(); else window.addEventListener("load", startup, false); }(wktimeln));
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址