您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
This UserScript is a descendant of the WaniKani Customizer Chrome Extension Timeline
// ==UserScript== // @name WaniKani Timeline // @namespace https://www.wanikani.com // @description This UserScript is a descendant of the WaniKani Customizer Chrome Extension Timeline // @version 0.1.5 // @include https://www.wanikani.com/ // @include https://www.wanikani.com/dashboard // @include https://www.wanikani.com/account // @run-at document-end // @grant none // ==/UserScript== /*jslint browser: true, plusplus: true*/ /*global $, console, alert, confirm */ /* rev 0.1.0: Almost all of this code is by Kyle Coburn aka kiko on WakiKani. It has been reformatted slightly and some minor changes made. rev 0.1.1: added a few more options for classic style: enable only fuzzy_time_mode_past, fuzzy_time_mode_near, and optionally twelve_hour_mode rev 0.1.2: change to fix server timeouts on higher level vocabulary change to prevent always reloading data when no current reviews increased max display time to 7-days changed data storage format. now stores number and type of current and burning items. (for more display options later) added a last updated time (works with 'WaniKani Real Times') added a basic loading and error indicator added 'wkt_username_' prefix to localStorage keys (for multiple WK account support) added a reload button added invalid API key detection. forget bad key. get new on refresh. rev 0.1.3: changed cacheTime format to seconds. Please click 'R' reload button once after upgrade! (if time stuck 'Just now') removed timestamp from timeTable format. now just calculate from cacheTime. added current/burn total visible counter tooltip on slider label changed default hours to 24 changed default vocab levels/request limit to 10 added a faint line between adjacent bars removed current/burn full-bar backgrounds added proportional RKV-specific current/burn marks triangle markers morph towards rectangles as they get very small to improve visibility don't draw offscreen graph bars (minor performance improvement) reverse tooltip when it would collide with browser right edge hardcode tRes time step to 15-minutes (WK-native interval) update graph while dragging scale bar automatically resize when needed on window resize event retry failed data requests on most errors (limit: twice). reduce timeout load issues. added request url marking to error display (title tag) last updated time 'WaniKani Real Times'-like behavior now integrated changed to clock-based time tic marks added hover highlight added green elapsed time highlight marker changed options.fuzzy_time_mode_past default to false added extended type display to tooltip (option flag to disable it) added summary tooltip left of graph changed tooltip tracking behavior (option flag to restore classic behavior) now show time tooltips for intervals with no reviews changed fuzzyMins from '-x mins' to 'x mins ago' rev 0.1.4: added 'hour_offset' option for people who want to offset from their system timezone changed 'current level' text in tooltip to 'current'. better length matching. rev 0.1.5: minor tooltip bugfix */ (function () { 'use strict'; var localStoragePrefix, ajaxCompletedCount, apiCalls, apiColors, startTime, timeZero, gHours, nextIdx, graphH, canvasH, xOff, vOff, maxHours, times, pastReviews, cacheTime, tFrac, options = {}; /* ### CONFIG OPTIONS ### */ options.hour_offset = 0; // offset displayed hours. Range: integer -23 to +23. (0 is your system timezone.) // options.twelve_hour_mode = true; // enable 12-hour AM/PM mode // options.fuzzy_time_mode_past = true; // enable '-x mins' mode for items now available // options.fuzzy_time_mode_near = true; // enable 'x mins' mode for upcoming items: now < time < now+90min options.fuzzy_time_paren = true; // append (x mins) to time for items: time < now+90min options.show_weekday = true; // show weekday prefix options.enable_arrows = true; // enable indicator arrows options.enable_extended_info = true; // show RKV breakdown in tooltips // options.classic_tooltip_tracking = true; // enable old style tooltip positioning /* ### END CONFIG ### */ apiColors = ['#0096E7', '#EE00A1', '#9800E8']; startTime = Math.floor(Date.now() / 1000); // in seconds timeZero = Math.ceil(startTime / 900); // get offset for next 15-minute time (from startTime for new data) gHours = 24; graphH = 88; canvasH = graphH + 15; xOff = 18; vOff = 16; maxHours = 24 * 7; function strNumSeq(min, max) { var i, str = ''; for (i = min; i <= max; i++) { if (str) { str += ','; } str += i; } return str; } function addSplitVocab(level) { var segCnt, segLen, min, max, vocabRequestLevelSplitSize = 10; // maximum number of levels per vocab API request segCnt = Math.ceil(level / vocabRequestLevelSplitSize); segLen = Math.ceil(level / segCnt); for (min = 1; min <= level; min += segLen) { max = min + segLen - 1; if (max > level) { max = level; } apiCalls.push('vocabulary/' + strNumSeq(min, max)); } } function getDashboardLevel() { var match, levelStr = $('section.progression h3').html(); if (levelStr) { match = levelStr.match(/Level (\d+) /); if (match && match.length === 2) { return parseInt(match[1], 10); } } return null; } function genListApiCalls() { var level; apiCalls = ['radicals', 'kanji']; level = getDashboardLevel(); if (level && 0 < level && level < 100) { // allow for level expansion addSplitVocab(level); } else { // if unknown level fail to no-split apiCalls.push('vocabulary'); } } function getPageUser() { var match, profileUrl = $('ul.nav a:contains("Profile")').prop('href'); if (profileUrl) { match = profileUrl.match('[^/]*$'); if (match && match.length === 1) { return match[0]; } } return ''; // blank if error } function debounce(func, wait) { var timeout; return function () { var context = this, args = arguments; function later() { timeout = null; func.apply(context, args); } clearTimeout(timeout); timeout = setTimeout(later, wait); }; } function pluralize(noun, amount) { return amount + ' ' + (amount === 1 ? noun : noun + 's'); } function pluralizeN(noun, amount) { return amount === 1 ? noun : noun + 's'; } // using round() instead of floor() means it only shows '1 min' for ~30 sec, but otherwise feels more 'right' in this case function fuzzyMins(minutes) { var seconds, negativeStr; negativeStr = (minutes < 0) ? ' ago' : ''; minutes = Math.abs(minutes); if (minutes < 1) { seconds = Math.round(minutes * 60); return pluralize('sec', seconds) + negativeStr; } minutes = Math.round(minutes); return pluralize('min', minutes) + negativeStr; } function getTimeAgoStr(time) { function fmt(unit, val) { return (val === 0) ? '' : (pluralize(unit, val) + ' '); } var timeMin = Math.floor((Date.now() - time) / 60000), day = fmt('day', Math.floor(timeMin / (24 * 60))), hrs = fmt('hour', Math.floor((timeMin % (24 * 60)) / 60)), min = fmt('minute', timeMin % 60); if (timeMin >= 24 * 60) { // gt 1 day return day + hrs + "ago"; } if (timeMin >= 1) { // gt 1 min return hrs + min + "ago"; } return "Just now"; } function drawCurrentBarBase(ctx, bx, by, width, height, color) { if (width <= 0) { return; } ctx.fillStyle = color; ctx.fillRect(bx, by, width, height); } function drawCurrentBarMark(ctx, bx, by, width, height, markColor) { var offX, offY; if (width <= 0) { return; } offX = 0; offY = height / 3; ctx.fillStyle = markColor; ctx.fillRect(bx + offX, by + offY, width - 2 * offX, height - 2 * offY); } function drawCurrentBars(ctx, maxCount, canvasW) { var type, width, typeWidth, gOff = xOff, height = graphH - vOff, pixelPerCount = (canvasW - xOff) / maxCount; for (type = 0; type < 3; ++type) { // total base bar typeWidth = pixelPerCount * pastReviews[type][0]; drawCurrentBarBase(ctx, gOff, vOff, typeWidth, height, apiColors[type]); // total // current width = pixelPerCount * pastReviews[type][1]; drawCurrentBarMark(ctx, gOff, vOff, width, height, '#FFFFFF'); // current // burn width = pixelPerCount * pastReviews[type][2]; drawCurrentBarMark(ctx, gOff + typeWidth - width, vOff, width, height, '#000000'); // burn gOff += typeWidth; } } function drawArrow(ctx, color, bx, tFrac) { var topY = 3 + graphH, halfWidthX = tFrac / 2, cenX = bx + halfWidthX, trapX; // trapezoid factor if (halfWidthX > 9) { // limit arrow width halfWidthX = 9; } if (halfWidthX > 2) { trapX = 0; } else if (halfWidthX > 1) { trapX = 0.75 * (2 - halfWidthX) * halfWidthX; } else { trapX = 0.75 * halfWidthX; } // console.log('trapX', halfWidthX, trapX, trapX / halfWidthX); ctx.fillStyle = color; ctx.beginPath(); ctx.moveTo(cenX + trapX, topY); ctx.lineTo(cenX - trapX, topY); ctx.lineTo(cenX - halfWidthX, topY + 10); ctx.lineTo(cenX + halfWidthX, topY + 10); ctx.fill(); } function drawFutureBarBase(ctx, bx, hOff, width, height, color) { var by, sepX; if (height <= 0) { return; } by = graphH - height - hOff; sepX = width * 0.1; // bar separation if (sepX > 1) { sepX = 1; } ctx.fillStyle = color; ctx.fillRect(bx + 0.5 * sepX, by, width - sepX, height); } function drawFutureBarMark(ctx, bx, hOff, width, height, color) { var by, offX, offY; if (height <= 0) { return; } by = graphH - height - hOff; offX = width / 3; offY = height * 0.1; if (offY > 1) { // upper limit offY = 1; } if (height <= 2) { // zero when small offY = 0; } ctx.fillStyle = color; ctx.fillRect(bx + offX, by + offY, width - 2 * offX, height - 2 * offY); } function drawFutureBars(ctx, maxCount) { var timeIdx, counts, type, hOff, height, bx, typeHeight, pixelPerCount = (graphH - vOff) / maxCount; for (timeIdx = 0; timeIdx < 4 * gHours && timeIdx < times.length; timeIdx++) { counts = times[timeIdx]; if (counts) { bx = xOff + timeIdx * tFrac; hOff = 0; for (type = 0; type < 3; ++type) { // total base bar typeHeight = pixelPerCount * counts[type][0]; drawFutureBarBase(ctx, bx, hOff, tFrac, typeHeight, apiColors[type]); // total // current height = pixelPerCount * counts[type][1]; drawFutureBarMark(ctx, bx, hOff, tFrac, height, '#FFFFFF'); // current // burn height = pixelPerCount * counts[type][2]; drawFutureBarMark(ctx, bx, hOff + typeHeight - height, tFrac, height, '#000000'); // burn hOff += typeHeight; } if (options.enable_arrows) { if (counts[0][1] || counts[1][1] || counts[2][1]) { drawArrow(ctx, '#FF0000', bx, tFrac); // current } else if (counts[0][2] || counts[1][2] || counts[2][2]) { drawArrow(ctx, '#A0A0A0', bx, tFrac); // burn } } } } } function genDateStr(tDate) { var hours, mins, suffix, weekdayText, weekday = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; hours = tDate.getHours(); mins = tDate.getMinutes(); suffix = ''; if (options.twelve_hour_mode) { suffix = ' ' + (hours < 12 ? 'am' : 'pm'); hours %= 12; if (hours === 0) { hours = 12; } } else { // don't zero-pad hours in 12-hour-mode if (hours < 10) { hours = '0' + hours; } } if (mins < 10) { mins = '0' + mins; } weekdayText = ''; if (options.show_weekday) { weekdayText = weekday[tDate.getDay()] + ' '; } return weekdayText + hours + ':' + mins + suffix; } function genTypeCountHtml(counts, category) { var count, type, str = ''; if (!options.enable_extended_info) { return ''; } for (type = 0; type < 3; type++) { if (counts[type][0]) { // if any of this type count = counts[type][category]; str += '<td><span class="wktTooltipTypeCount" style="color: ' + apiColors[type] + '">' + (count > 0 ? count : ' ') + '</span></td>'; } } return str; } function setTooltipHtml(elem, timeIdx) { var reviewCount, currentCount, burnCount, showTime, minDiff, tDisplay, tText, fuzzyExtra, counts, totalCounts, type, cat; if (gHours === 0) { counts = pastReviews; tDisplay = '<span class="wktSummary">Summary</span>'; } else { if (timeIdx >= 0) { counts = times[timeIdx]; showTime = new Date(1000 * (timeZero + timeIdx) * 900); minDiff = (showTime - Date.now()) / (1000 * 60); if (options.fuzzy_time_mode_past && minDiff < 0) { tDisplay = fuzzyMins(minDiff); } else if (options.fuzzy_time_mode_near && 0 <= minDiff && minDiff < 90) { tDisplay = fuzzyMins(minDiff); } else { fuzzyExtra = ''; if (options.fuzzy_time_paren && minDiff < 90) { fuzzyExtra = ' (' + fuzzyMins(minDiff) + ')'; } if (options.hour_offset) { showTime.setHours(showTime.getHours() + options.hour_offset); } tDisplay = genDateStr(showTime) + fuzzyExtra; } if (minDiff <= 0) { tDisplay = '<span class="wktPastTime">' + tDisplay + '</span>'; } } else { totalCounts = []; totalCounts[0] = [0, 0, 0]; totalCounts[1] = [0, 0, 0]; totalCounts[2] = [0, 0, 0]; for (timeIdx = 0; timeIdx < 4 * gHours && timeIdx < times.length; timeIdx++) { counts = times[timeIdx]; if (counts) { for (type = 0; type < 3; type++) { for (cat = 0; cat < 3; cat++) { totalCounts[type][cat] += counts[type][cat]; } } } } counts = totalCounts; tDisplay = '<span class="wktSummary">Summary</span>'; } } if (counts) { reviewCount = counts[0][0] + counts[1][0] + counts[2][0]; currentCount = counts[0][1] + counts[1][1] + counts[2][1]; burnCount = counts[0][2] + counts[1][2] + counts[2][2]; tText = tDisplay + '<table><tr><td class="wktTooltipCount">' + reviewCount + '</td><td class="wktTooltipLabel">' + pluralizeN('review', reviewCount) + '</td>' + genTypeCountHtml(counts, 0) + '</tr>'; if (currentCount) { tText += '<tr><td class="wktTooltipCount">' + currentCount + '</td><td class="wktTooltipLabel"><em>current</em></td>' + genTypeCountHtml(counts, 1) + '</tr>'; } if (burnCount) { tText += '<tr><td class="wktTooltipCount">' + burnCount + '</td><td class="wktTooltipLabel"><em>burning</em></td>' + genTypeCountHtml(counts, 2) + '</tr>'; } tText += '</table>'; elem.html(tText); if (options.enable_extended_info) { // brute force first/last corner rounding... elem.find('tr').each(function (index, element) { var span = $(element).find('.wktTooltipTypeCount'); span.first().addClass('wktTooltipTypeFirst'); span.last().addClass('wktTooltipTypeLast'); }); } } else { elem.html(tDisplay); } } function updateTooltipPos(gTip, event, barW, graphLeft, idx) { var leftPosX, tooltipW, rightMargin; if (options.classic_tooltip_tracking) { leftPosX = graphLeft + (idx + 1) * barW; tooltipW = gTip.outerWidth(); rightMargin = $(window).width() - leftPosX - tooltipW; gTip.css({ 'left': (rightMargin > 5) ? leftPosX : (leftPosX - tooltipW - barW), 'top': event.pageY - gTip.height() - 6 }); } else { leftPosX = event.pageX + 10; tooltipW = gTip.outerWidth(); rightMargin = $(window).width() - leftPosX - tooltipW; gTip.css({ 'left': (rightMargin > 5) ? leftPosX : (leftPosX - tooltipW - 15), 'top': event.pageY + 10 }); } } function initTooltip() { var gTip, highlight, canvasJQ, prevIdx = null; gTip = $('#wktTooltip'); highlight = $('#wktTooltipHighlight'); canvasJQ = $('#wktCanvas'); canvasJQ.mousemove(function (event) { var xPos, yPos, barW, idx, graphLeft, graphTop; graphLeft = canvasJQ.offset().left + xOff; graphTop = canvasJQ.offset().top + vOff; xPos = event.pageX - graphLeft; yPos = event.pageY - graphTop; if (yPos < 0) { // ignore mouse in top margin if (prevIdx !== null) { canvasJQ.mouseleave(); } return; } if (xPos < 0) { barW = xOff; idx = -1; } else { if (gHours === 0) { // no normal tooltip for past reviews if (prevIdx !== null) { canvasJQ.mouseleave(); } return; } barW = tFrac; idx = Math.floor(xPos / barW); } if (idx !== prevIdx) { setTooltipHtml(gTip, idx); updateTooltipPos(gTip, event, barW, graphLeft, idx); highlight.css({ 'left': graphLeft + idx * barW, 'top': graphTop, 'height': graphH - vOff, 'width': barW }); gTip.show(); highlight.show(); prevIdx = idx; } else { updateTooltipPos(gTip, event, barW, graphLeft, idx); } }); canvasJQ.mouseleave(function () { gTip.hide(); highlight.hide(); prevIdx = null; }); } function drawTimeMark(ctx, canvasW, timeIdx) { var str, textHalfWidth, isTic, textClipped, hour, xP = Math.floor(xOff + (timeIdx + 0.5) * tFrac), date = new Date(1000 * (timeZero + timeIdx) * 900); if (options.hour_offset) { date.setHours(date.getHours() + options.hour_offset); } hour = date.getHours(); if (gHours <= 6) { isTic = true; } else if (gHours <= 24) { isTic = hour % 3 === 0; } else if (gHours <= 48) { isTic = hour % 6 === 0; } else if (gHours <= 96) { if (hour % 2) { return; } isTic = hour % 12 === 0; } else { if (hour % 3) { return; } isTic = hour === 0; } // draw line ctx.fillStyle = isTic ? '#CCCCCC' : '#E4E4E4'; ctx.fillRect(xP, vOff, 1, graphH - vOff); if (isTic) { // draw tic mark ctx.fillStyle = '#A0A0A0'; ctx.fillRect(xP, vOff - 4, 1, 4); str = genDateStr(date); textHalfWidth = ctx.measureText(str).width / 2; // console.log('offset', xP - textHalfWidth, canvasW - (xP + textHalfWidth)); textClipped = xP - textHalfWidth <= 0 || xP + textHalfWidth >= canvasW; if (!textClipped) { // don't draw if text would be clipped // draw text ctx.fillStyle = '#505050'; ctx.fillText(str, xP, 0); } } } function getNextIndex() { var timeNow = Math.floor(Date.now() / 1000); // in seconds return Math.ceil(timeNow / 900) - timeZero; } function drawCanvas() { var ctx, canvasW, counts, columnRevCnt, timeIdx, canvas = document.getElementById('wktCanvas'), reviewCount = 0, currentCount = 0, burnCount = 0, maxCount = 1; // console.log('drawCanvas'); if (!canvas.getContext) { return; } canvasW = $('.span12 header').width(); tFrac = (canvasW - xOff) / (4 * gHours); // pixels per column canvas.width = canvasW; // has side effect of clear ctx = canvas.getContext('2d'); // calculate totals if (gHours === 0) { counts = pastReviews; if (counts) { reviewCount = counts[0][0] + counts[1][0] + counts[2][0]; currentCount = counts[0][1] + counts[1][1] + counts[2][1]; burnCount = counts[0][2] + counts[1][2] + counts[2][2]; maxCount = reviewCount; } } else { for (timeIdx = 0; timeIdx < 4 * gHours && timeIdx < times.length; timeIdx++) { counts = times[timeIdx]; if (counts) { columnRevCnt = counts[0][0] + counts[1][0] + counts[2][0]; currentCount += counts[0][1] + counts[1][1] + counts[2][1]; burnCount += counts[0][2] + counts[1][2] + counts[2][2]; reviewCount += columnRevCnt; if (columnRevCnt > maxCount) { maxCount = columnRevCnt; } } } maxCount = Math.ceil(maxCount / 2) * 2; // round up to nearest even number } $('#wktRangeLabelReviewCount').text(pluralize('review', reviewCount)); $('#wktRangeLabel').prop('title', 'Current ' + currentCount + ' Burn ' + burnCount); // text config general ctx.textBaseline = 'top'; ctx.textAlign = 'right'; ctx.font = '12px sans-serif'; // text maxCount (left) ctx.fillStyle = '#505050'; ctx.fillText(maxCount, xOff - 4, vOff + 1); // line v-right ctx.fillStyle = '#E4E4E4'; ctx.fillRect(canvasW - 1, vOff, 1, graphH - vOff); if (gHours !== 0) { // line h-top ctx.fillStyle = '#E4E4E4'; ctx.fillRect(0, vOff - 1, canvasW, 1); // line h-center ctx.fillStyle = '#E4E4E4'; ctx.fillRect(0, Math.floor((vOff + graphH) * 0.5), canvasW, 1); // text maxCount-half (left) ctx.fillStyle = '#505050'; ctx.fillText(maxCount / 2, xOff - 4, Math.floor((vOff + graphH) * 0.5) + 2); // lines vertical ctx.textAlign = 'center'; timeIdx = Math.ceil(timeZero / 4) * 4 - timeZero; // next hour for (0; timeIdx < 4 * gHours; timeIdx += 4) { drawTimeMark(ctx, canvasW, timeIdx); } } // line v-left ctx.fillStyle = '#D4D4D4'; ctx.fillRect(xOff - 2, vOff, 1, graphH - vOff); ctx.fillStyle = '#FFFFFF'; ctx.fillRect(xOff - 1, vOff, 1, graphH - vOff); // line h-bottom ctx.fillStyle = '#D4D4D4'; ctx.fillRect(0, graphH, canvasW, 1); ctx.fillStyle = '#FFFFFF'; ctx.fillRect(0, graphH + 1, canvasW, 1); if (gHours === 0) { drawCurrentBars(ctx, maxCount, canvasW); } else { nextIdx = getNextIndex(); if (nextIdx > 0) { // elapsed back highlight ctx.fillStyle = 'rgba(0,255,0,0.1)'; ctx.fillRect(xOff, vOff, tFrac * nextIdx, graphH - vOff); } drawFutureBars(ctx, maxCount); if (nextIdx > 0) { // elapsed bar ctx.fillStyle = '#00FF00'; ctx.fillRect(Math.floor(xOff + tFrac * nextIdx), vOff - 4, 1, graphH - vOff + 9); } } } function triggerInputDraw() { gHours = null; // to trigger change detection $('#wktRange').trigger('input'); // triggers drawCanvas() } function initUpdateElapsed() { setInterval(function () { if (getNextIndex() > nextIdx) { triggerInputDraw(); } }, 30 * 1000); } function initUpdateTime() { var isoString, utcString, date, elem; date = new Date(1000 * cacheTime); isoString = date.toISOString(); utcString = isoString.replace(/(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})\.\d{3}Z/, '$1 $2 UTC'); elem = $('#wktCacheTime'); elem.prop('title', 'Updated: ' + utcString); elem.html(getTimeAgoStr(date)); setInterval(function () { elem.html(getTimeAgoStr(date)); }, 30 * 1000); } function bindEventSlider() { var drawCanvasDebounce = debounce(drawCanvas, 50); function onSliderInput() { var newVal = Number($(this).val()); if (gHours === newVal) { return; } // console.log('input fired', gHours, newVal); gHours = newVal; if (gHours < 6) { gHours = pastReviews ? 0 : 3; } $('#wktRangeLabelTimeframe').text(gHours === 0 ? 'right now' : 'in ' + gHours + ' hours'); drawCanvasDebounce(); } $('#wktRange').on('input', onSliderInput); } function bindEventResize() { function onResizeEvent() { // console.log('onResizeEvent'); if ($('#wktCanvas').width() !== $('.span12 header').width()) { // check if canvas actually needs resize triggerInputDraw(); // triggers drawCanvas() } } $(window).resize(debounce(onResizeEvent, 250)); } function getFirstReviewSliderHours() { var idx; for (idx = 0; idx < times.length; idx++) { if (times[idx]) { return Math.ceil((idx + 1) / (4 * 6)) * 6; } } return 0; } function initCanvas() { var reviewHours = getFirstReviewSliderHours(); if (reviewHours > gHours) { gHours = reviewHours; } timeZero = Math.ceil(cacheTime / 900); // get offset for next 15-minute time (from cacheTime for old or new data) initTooltip(); initUpdateTime(); bindEventSlider(); bindEventResize(); $('#wktLoading').hide(); $('#wktSection').show(); $('#wktRange').val(gHours); // set slider triggerInputDraw(); // triggers drawCanvas() initUpdateElapsed(); } function appendError(newError) { var error = $('#wktLoadingError').html(); if (!error) { error = 'Error: '; } else { error += ', '; } error += newError; $('#wktLoadingError').html(error); } function appendErrorTitle(error, title) { appendError('<span title="' + title + '">' + error + '</span>'); } // Load data function addData(data) { var typeIdx, itemIdx, timeIdx, response, myLevel, item, stats, timeTable; response = data.requested_information; if (!response) { if (data.error) { if (data.error.code === 'user_not_found') { appendErrorTitle('badApiKey', this.urlTag); localStorage.removeItem(localStoragePrefix + 'apiKey'); // remove invalid key return; } } appendErrorTitle('badResponse', this.urlTag); return; } if (response.general) { response = response.general; } myLevel = data.user_information.level; typeIdx = response[0].kana ? 2 : response[0].important_reading ? 1 : 0; // determine RKV type for (itemIdx = 0; itemIdx < response.length; itemIdx++) { item = response[itemIdx]; stats = item.user_specific; if (stats && !stats.burned) { timeIdx = Math.round(stats.available_date / 900) - timeZero; // should be evenly divisible by 900 if (timeIdx < maxHours * 4) { // if next review time is before graph limit if (timeIdx < 0) { if (!pastReviews) { pastReviews = []; // init object } timeTable = pastReviews; } else { if (!times) { times = []; // init object } if (!times[timeIdx]) { times[timeIdx] = []; // init object } timeTable = times[timeIdx]; } if (!timeTable[0]) { timeTable[0] = [0, 0, 0]; // 0:radical [0:total, 1:current, 2:burn] timeTable[1] = [0, 0, 0]; // 1:kanji timeTable[2] = [0, 0, 0]; // 2:vocab } timeTable[typeIdx][0]++; // add item to r0/k1/v2 bin total if (typeIdx < 2 && item.level === myLevel && stats.srs === 'apprentice') { timeTable[typeIdx][1]++; // increment current } else if (stats.srs === 'enlighten') { timeTable[typeIdx][2]++; // increment burn } } } } ajaxCompletedCount++; if (ajaxCompletedCount === apiCalls.length && times && times.length > 0) { cacheTime = startTime; localStorage.setItem(localStoragePrefix + 'reviewCache', JSON.stringify(times)); localStorage.setItem(localStoragePrefix + 'pastCache', JSON.stringify(pastReviews)); localStorage.setItem(localStoragePrefix + 'cacheTime', cacheTime); initCanvas(); } else { $('#wktLoadingCount').html(ajaxCompletedCount + '/' + apiCalls.length); if (ajaxCompletedCount >= apiCalls.length) { appendError('noData'); // all request completed, none contained usable data } } } function ajaxError(xhr, textStatus, thrownError) { appendErrorTitle(thrownError, this.urlTag); // console.log('ajaxError', this); if (this.retry > 0) { // retry remaining this.retry--; $.ajax(this); } } function getNewData(apiKey) { var ext; localStorage.setItem(localStoragePrefix + 'reviewCache', null); localStorage.setItem(localStoragePrefix + 'pastCache', null); times = null; pastReviews = null; ajaxCompletedCount = 0; genListApiCalls(); $('#wktLoadingCount').html(ajaxCompletedCount + '/' + apiCalls.length); for (ext = 0; ext < apiCalls.length; ext++) { $.ajax({ type: 'get', url: '/api/v1.4/user/' + apiKey + '/' + apiCalls[ext], urlTag: apiCalls[ext], success: addData, error: ajaxError, retry: 2 }); } } function insertTimeline() { var apiKey; apiKey = localStorage.getItem(localStoragePrefix + 'apiKey'); if (!apiKey || apiKey.length !== 32) { alert('Hang on! We\'re grabbing your API key for the Reviews Timeline. We should only need to do this once.'); document.location.pathname = '/account'; return; } $('section.review-status').before('<div id="wktLoading">Reviews Timeline Loading: <span id="wktLoadingCount"></span> <span id="wktLoadingError"></span></div><section id="wktSection"><h4>Reviews Timeline</h4><a id="wktHelp">?</a> <a id="wktReload" title="reload (clear timeline cache)">R</a> <time id="wktCacheTime"></time><form id="wktForm"><label><span id="wktRangeLabel"><span id="wktRangeLabelReviewCount"></span> <span id="wktRangeLabelTimeframe"></span> </span><input id="wktRange" type="range" min="0" max="' + maxHours + '" step="6"></label></form><div id="wktTooltipHighlight"></div><div id="wktTooltip"></div><canvas id="wktCanvas" height="' + canvasH + '"></canvas></section>'); $('#wktHelp').click(function () { alert('Reviews Timeline - Displays your upcoming reviews\nY-axis: Number of reviews\nX-axis: Time (scale set by the slider)\n\nWhite markings and red arrows indicate radicals/kanji necessary for advancing your current level.\nBlack markings and grey arrows indicate burnable items.\n\nThe time display indicates how long ago the Timeline was updated.\nIt will download new data when this exceeds 1-hour and you refresh the page.'); }); $('#wktReload').click(function () { if (confirm('Reviews Timeline: Reload Confirmation\n\nClick OK to clear the cache and refresh the page.\n\nWarning:\nExcessive API requests may be blocked by the server.') === true) { localStorage.removeItem(localStoragePrefix + 'reviewCache'); document.location.reload(); } }); try { // JSON.parse will throw SyntaxError on error cacheTime = Number(localStorage.getItem(localStoragePrefix + 'cacheTime')); times = JSON.parse(localStorage.getItem(localStoragePrefix + 'reviewCache')); pastReviews = JSON.parse(localStorage.getItem(localStoragePrefix + 'pastCache')); } catch (ignore) {} if (times && (!cacheTime || startTime - cacheTime >= 3600)) { // reload if gt 1-hour old data times = null; } if (!times || times.length === 0) { // load new data getNewData(apiKey); } else { // using cached data setTimeout(initCanvas, 0); } } // from: https://gist.githubusercontent.com/arantius/3123124/raw/grant-none-shim.js 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; } function insertGlobalCss() { addStyle('\n' + 'span#wktLoadingError {\n' + ' color: red;\n' + '}\n' + 'div#wktTooltipHighlight {\n' + ' position: absolute;\n' + ' background-color: rgba(0,0,0,0.1);\n' + ' pointer-events: none;\n' + ' display: none;\n' + // initial '}\n' + 'div#wktTooltip {\n' + ' padding: 2px 8px;\n' + ' position: absolute;\n' + ' color: #EEEEEE;\n' + ' background-color: rgba(0,0,0,0.6);\n' + ' border-radius: 4px;\n' + ' pointer-events: none;\n' + ' font-weight: bold;\n' + ' white-space: nowrap;\n' + ' display: none;\n' + // initial '}\n' + 'div#wktTooltip .wktPastTime {\n' + ' color: #FFFFFF;\n' + ' text-shadow: 0 0 1px #00FF00;\n' + '}\n' + 'div#wktTooltip .wktSummary {\n' + ' color: #FFFFFF;\n' + ' text-shadow: 0 0 1px #FF0000;\n' + '}\n' + 'div#wktTooltip td {\n' + ' padding: 0;\n' + '}\n' + 'div#wktTooltip td.wktTooltipCount {\n' + ' padding-right: 5px;\n' + ' text-align: right;\n' + '}\n' + 'div#wktTooltip .wktTooltipTypeCount {\n' + ' padding: 0 0.25em;\n' + ' background-color: rgba(255,255,255,0.75);\n' + ' display: block;\n' + ' text-align: center;\n' + ' line-height: 1;\n' + '}\n' + 'div#wktTooltip .wktTooltipTypeFirst {\n' + ' border-top-left-radius: 4px;\n' + ' border-bottom-left-radius: 4px;\n' + ' margin-left: 10px;\n' + '}\n' + 'div#wktTooltip .wktTooltipTypeLast {\n' + ' border-top-right-radius: 4px;\n' + ' border-bottom-right-radius: 4px;\n' + '}\n' + 'section#wktSection {\n' + ' overflow: hidden;\n' + ' margin-bottom: 0px;\n' + ' height: ' + (options.enable_arrows ? '130' : '117') + 'px;\n' + ' display: none;\n' + // initial '}\n' + 'form#wktForm {\n' + ' float: right;\n' + ' margin-bottom: 0px;\n' + ' margin-right: 1px;\n' + ' min-width: 50%;\n' + ' text-align: right;\n' + '}\n' + 'section#wktSection h4 {\n' + ' clear: none;\n' + ' float: left;\n' + ' height: 20px;\n' + ' margin-top: 0px;\n' + ' margin-bottom: 4px;\n' + ' font-weight: normal;\n' + ' margin-right: 12px;\n' + '}\n' + 'a#wktHelp, a#wktReload {\n' + ' font-weight: bold;\n' + ' color: rgba(0, 0, 0, 0.1);\n' + ' font-size: 1.2em;\n' + ' line-height: 0px;\n' + '}\n' + 'a#wktHelp:hover, a#wktReload:hover {\n' + ' text-decoration: none;\n' + ' cursor: help;\n' + ' color: rgba(0, 0, 0, 0.5);\n' + '}\n' + '@media (max-width: 767px) {\n' + ' section#wktSection h4 {\n' + ' display: none;\n' + ' }\n' + '}\n'); } function updateApiKey() { var apiKey, alreadySaved; apiKey = $('input[placeholder="Key has not been generated"]').val(); if (apiKey) { alreadySaved = localStorage.getItem(localStoragePrefix + 'apiKey'); localStorage.setItem(localStoragePrefix + 'apiKey', apiKey); console.log('WaniKani Timeline Updated API Key: ' + apiKey); if (!alreadySaved) { document.location.pathname = '/dashboard'; } } } function init() { var username = getPageUser(); if (!username) { console.log('WaniKani Timeline: failed to get username. probably not logged in. abort.'); return; } localStoragePrefix = 'wkt_' + username + '_'; if (document.location.pathname === '/account') { updateApiKey(); } else { insertGlobalCss(); insertTimeline(); } } init(); console.log('WaniKani Timeline: script load end'); }());