WaniKani Timeline

This is the WaniKani Customizer Chrome Extension Timeline ported to UserScript

当前为 2014-10-15 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name          WaniKani Timeline
// @namespace     https://www.wanikani.com
// @description   This is the WaniKani Customizer Chrome Extension Timeline ported to UserScript
// @version       0.1.1
// @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 */

/*
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
*/

(function () {
    'use strict';
    var tRes, counted, api_calls, api_colors, curr_date, start_time, gHours, graphH, graphH_canvas, styleCss,
        xOff, vOff, max_hours, times, pastReviews, firstReview, tFrac, page_width, options = {};
    /* ### CONFIG OPTIONS ### */
    // 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
    /* ### END CONFIG ### */
    counted = 0;
    api_calls = ['radicals', 'kanji', 'vocabulary'];
    api_colors = ['#0096e7', '#ee00a1', '#9800e8'];
    curr_date = new Date();
    start_time = curr_date.getTime() / 1000;
    gHours = 12;
    graphH = 88;
    graphH_canvas = graphH + 15;
    xOff = 18;
    vOff = 16;
    max_hours = 72;
    // Helpers
    function pluralize(noun, amount) {
        return amount + ' ' + (amount !== 1 ? noun + 's' : noun);
    }
    function fuzzyMins(minutes) {
        var seconds;
        if (minutes < 1 && minutes > 0) {
            seconds = Math.round(minutes * 60);
            return pluralize('sec', seconds);
        }
        minutes = Math.round(minutes);
        return pluralize('min', minutes);
    }
    // Draw
    function drawBarRect(ctx, xo, yo, bw, bh, color) {
        ctx.fillStyle = color;
        ctx.fillRect(xo, yo, bw, bh);
    }
    function drawBar(ctx, time, height, hOff, color, tFrac, outlined) {
        var bx = xOff + time * tFrac, by = graphH - height - hOff;
        ctx.fillStyle = color;
        ctx.fillRect(bx, by, tFrac, height);
        if (outlined) {
            ctx.strokeStyle = (outlined === -1 ? '#ffffff' : '#000000');
            //ctx.strokeRect(bx, by, tFrac, hOff === 0 ? graphH : height);
            ctx.beginPath();
            ctx.moveTo(bx, by + height);
            ctx.lineTo(bx, by);
            ctx.lineTo(bx + tFrac, by);
            ctx.lineTo(bx + tFrac, by + height);
            if (hOff !== 0) { // don't draw stroke on top of axis
                ctx.lineTo(bx, by + height);
            }
            ctx.stroke();
        }
    }
    function drawArrow(ctx, currentType, xOff, time, tFrac) {
        if (currentType === -1) {
            ctx.fillStyle = '#FF0000';
        } else if (currentType === -2) {
            ctx.fillStyle = '#A0A0A0';
        } else {
            return;
        }
        var bx = xOff + (time - 1) * tFrac,
            topY = 3 + graphH,
            halfWidthX = tFrac / 2,
            cenX = bx + halfWidthX;
        if (halfWidthX > 9) { // limit arrow width
            halfWidthX = 9;
        }
        ctx.beginPath();
        ctx.moveTo(cenX, topY);
        ctx.lineTo(cenX - halfWidthX, topY + 10);
        ctx.lineTo(cenX + halfWidthX, topY + 10);
        ctx.fill();
        //ctx.lineTo(cenX, topY);
        //ctx.strokeStyle = "#000000";
        //ctx.stroke();
    }
    function drawCanvas(clear) {
        var totalCount, maxCount, graphTimeScale, ti, time, counts, total, ctx,
            gTip, pidx, canvasJQ, idx, rCount, showTime, minDiff, tDisplay, tDate, hours, mins, suffix, tText, currentType,
            hrsFrac, gOff, height, count, i, width, hOff, xP,
            canvas = document.getElementById('c-timeline'),
            fuzzyExtra, weekdayText, weekday = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
        if (canvas.getContext) {
            totalCount = 0;
            maxCount = 3;
            graphTimeScale = 60 * 60 * (gHours - 0.1);
            if (gHours === 0) {
                if (pastReviews) {
                    for (ti = 0; ti < 3; ++ti) {
                        totalCount += pastReviews[ti];
                    }
                    maxCount = totalCount;
                }
            } else {
                for (time = 0; time < times.length; time++) {
                    if (time * 60 * tRes < graphTimeScale) {
                        counts = times[time];
                        if (counts) {
                            total = 0;
                            for (ti = 0; ti < 3; ++ti) {
                                total += counts[ti];
                            }
                            if (total > maxCount) {
                                maxCount = total;
                            }
                            totalCount += total;
                        }
                    }
                }
            }
            if (totalCount === 0) {
                maxCount = 0;
            }
            $('#g-timereviews').text(totalCount);
            tFrac = tRes * (page_width - xOff) / 60 / gHours;
            ctx = canvas.getContext("2d");
            if (clear) {
                ctx.clearRect(0, 0, page_width, graphH_canvas);
                page_width = $('.span12 header').width();
            } else {
                gTip = $('#graph-tip');
                canvasJQ = $('#c-timeline');
                canvas.addEventListener('mousemove', function (event) {
                    if (gHours === 0) {
                        return;
                    }
                    //~ idx = Math.floor((event.offsetX - xOff) / tFrac) + 1;
                    idx = Math.floor(((event.pageX - canvasJQ.offset().left) - xOff) / tFrac) + 1;
                    if (idx !== pidx) {
                        counts = times[idx];
                        if (counts) {
                            gTip.show();
                            rCount = counts[0] + counts[1] + counts[2];
                            showTime = counts[4] * 1000;
                            minDiff = (showTime - new Date().getTime()) / 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 {
                                tDate = new Date(showTime);
                                hours = tDate.getHours();
                                mins = tDate.getMinutes();
                                suffix = '';
                                if (options.twelve_hour_mode) {
                                    suffix = ' ' + (hours < 12 ? 'am' : 'pm');
                                    hours %= 12;
                                    if (hours === 0) {
                                        hours = 12;
                                    }
                                }
                                if (hours < 10) {
                                    hours = '0' + hours;
                                }
                                if (mins < 10) {
                                    mins = '0' + mins;
                                }
                                weekdayText = '';
                                if (options.show_weekday) {
                                    weekdayText = weekday[tDate.getDay()] + ' ';
                                }
                                fuzzyExtra = '';
                                if (options.fuzzy_time_paren && minDiff < 90) {
                                    fuzzyExtra = '&nbsp;&nbsp;(' + fuzzyMins(minDiff) + ')';
                                }
                                tDisplay = weekdayText + hours + ':' + mins + suffix + fuzzyExtra;
                            }
                            tText = tDisplay + '<br />' + pluralize('review', rCount);
                            currentType = counts[3];
                            if (currentType) {
                                tText += '<br /><em>';
                                tText += currentType === -1 ? 'current level' : 'burning';
                                tText += '</em>';
                            }
                            gTip.html(tText);
                            gTip.css({
                                left: canvasJQ.position().left + idx * tFrac + xOff,
                                top: event.pageY - gTip.height() - 6
                            });
                        } else {
                            gTip.hide();
                        }
                        pidx = idx;
                    } else {
                        gTip.css('top', event.pageY - gTip.height() - 6);
                    }
                }, false);
                canvasJQ.mouseleave(function () {
                    gTip.hide();
                    pidx = null;
                });
            }
            canvas.width = page_width;
            hrsFrac = gHours / 3;
            ctx.lineWidth = tFrac / 20;
            ctx.strokeStyle = "#ffffff";
            ctx.textBaseline = 'top';
            ctx.textAlign = 'right';
            ctx.font = '12px sans-serif';
            ctx.fillStyle = '#e4e4e4';
            if (gHours !== 0) {
                ctx.fillRect(0, Math.floor((vOff + graphH) * 0.5), page_width, 1);
            }
            ctx.fillRect(0, vOff - 1, page_width, 1);
            ctx.fillStyle = '#505050';
            ctx.textAlign = 'right';
            ctx.fillText(maxCount, xOff - 4, vOff + 1);
            // left border
            ctx.fillStyle = '#d4d4d4';
            ctx.fillRect(xOff - 2, 0, 1, graphH);
            ctx.fillStyle = '#ffffff';
            ctx.fillRect(xOff - 1, 0, 1, graphH);
            // bottom border
            ctx.fillStyle = '#d4d4d4';
            ctx.fillRect(0, graphH, page_width, 1);
            ctx.fillStyle = '#ffffff';
            ctx.fillRect(0, graphH + 1, page_width, 1);
            if (gHours === 0) {
                if (pastReviews) {
                    gOff = xOff;
                    height = graphH - vOff;
                    for (ti = 0; ti < 3; ++ti) {
                        count = pastReviews[ti];
                        if (count > 0) {
                            width = Math.ceil(count / maxCount * (page_width - xOff));
                            drawBarRect(ctx, gOff, vOff, width, height, api_colors[ti]);
                            gOff += width;
                        }
                    }
                }
            } else {
                for (i = 1; i < 4; ++i) {
                    xP = Math.floor(i / 3 * (page_width - 2));
                    if (i === 3) {
                        xP += 1;
                    } else if (page_width > 1100) {
                        --xP;
                    }
                    ctx.fillStyle = '#e4e4e4';
                    ctx.fillRect(xP, 0, 1, graphH_canvas);
                    ctx.fillStyle = '#505050';
                    ctx.fillText(String(hrsFrac * i), xP - 2, 0);
                }
                for (time = 0; time < times.length; time++) {
                    counts = times[time];
                    if (counts) {
                        hOff = 0;
                        currentType = counts[3];
                        if (currentType) {
                            drawBar(ctx, time - 1, graphH - vOff, 0, 'rgba(' + (currentType === -1 ? '255, 255, 255' : '0, 0, 0') + ', 0.33)', tFrac);
                        }
                        for (ti = 0; ti < 3; ++ti) {
                            count = counts[ti];
                            if (count > 0) {
                                height = Math.ceil(count / maxCount * (graphH - vOff));
                                drawBar(ctx, time - 1, height, hOff, api_colors[ti], tFrac, currentType);
                                hOff += height;
                            }
                        }
                        if (options.enable_arrows) {
                            drawArrow(ctx, currentType, xOff, time, tFrac);
                        }
                    }
                }
            }
        }
    }
    function initCanvas() {
        var reviewHours = Math.ceil(firstReview / 60 / 60 / 6) * 6;
        $('#r-timeline').show();
        if (reviewHours > gHours) {
            gHours = reviewHours;
            $('#g-range').attr('value', gHours); // set slider
            $('#g-range').trigger('change'); // update slider text / draw
        }
        drawCanvas(); // need to init hover, even if redraw
    }
    // Load data
    function addData(data) {
        var itemIdx, response, myLevel, firstItem, typeIdx, maxSeconds, item, stats, availableAt, tDiff, timeIdx, timeTable;
        response = data.requested_information;
        if (response) {
            if (response.general) {
                response = response.general;
            }
            myLevel = data.user_information.level;
            firstItem = response[0];
            typeIdx = firstItem.kana ? 2 : firstItem.important_reading ? 1 : 0;
            maxSeconds = 60 * 60 * max_hours;
            for (itemIdx = 0; itemIdx < response.length; itemIdx++) {
                item = response[itemIdx];
                stats = item.user_specific;
                if (stats && !stats.burned) {
                    availableAt = stats.available_date;
                    tDiff = availableAt - start_time;
                    if (tDiff < maxSeconds) {
                        if (tDiff < firstReview) {
                            firstReview = tDiff;
                        }
                        timeIdx = tDiff < 1 ? -1 : Math.round(tDiff / 60 / tRes) + 1;
                        if (tDiff < 0) {
                            if (!pastReviews) {
                                pastReviews = [0, 0, 0, 0, availableAt];
                            }
                            timeTable = pastReviews;
                        } else {
                            timeTable = times[timeIdx];
                        }
                        if (!timeTable) {
                            times[timeIdx] = [0, 0, 0, 0, availableAt];
                            timeTable = times[timeIdx];
                        } else if (availableAt < timeTable[4]) {
                            timeTable[4] = availableAt;
                        }
                        ++timeTable[typeIdx]; // add item to r0/k1/v2 bin
                        if (timeTable[3] !== -1) { // change to give current level priority
                            if (typeIdx < 2 && item.level === myLevel && stats.srs === 'apprentice') {
                                timeTable[3] = -1;
                            } else if (stats.srs === 'enlighten') {
                                timeTable[3] = -2;
                            }
                        }
                    }
                }
            }
            if (++counted === 3 && times && times.length > 0) {
                localStorage.setItem('reviewCache', JSON.stringify(times));
                localStorage.setItem('pastCache', JSON.stringify(pastReviews));
                localStorage.setItem('cacheExpiration', curr_date.getTime());
                initCanvas();
            }
        }
    }
    function insertTimeline() {
        var apiKey, cacheExpires, ext, idx, counts;
        apiKey = localStorage.getItem('apiKey');
        if (apiKey && apiKey.length === 32) {
            $('section.review-status').before('<section id="r-timeline" style="display: none;"><h4>Reviews Timeline</h4><a class="help">?</a><form id="graph-form"><label><span id="g-timereviews"></span> reviews <span id="g-timeframe">in ' + gHours + ' hours</span> <input id="g-range" type="range" min="0" max="' + max_hours + '" value="' + gHours + '" step="6" name="g-ranged"></label></form><div id="graph-tip" style="display: none;"></div><canvas id="c-timeline" height="' + graphH_canvas + '"></canvas></section>');
            try {
                times = JSON.parse(localStorage.getItem('reviewCache'));
                pastReviews = JSON.parse(localStorage.getItem('pastCache'));
            } catch (ignore) {}
            if (times && pastReviews) {
                cacheExpires = localStorage.getItem('cacheExpiration');
                if (cacheExpires && curr_date - cacheExpires > 60 * 60 * 1000) {
                    times = null;
                }
            }
            if (!times || !pastReviews || times.length === 0) {
                times = null;
                pastReviews = null;
                localStorage.setItem('reviewCache', null);
                localStorage.setItem('pastCache', null);
                times = [];
                firstReview = Number.MAX_VALUE;
                for (ext = 0; ext < api_calls.length; ext++) {
                    $.ajax({
                        type: 'get',
                        url: '/api/v1.2/user/' + apiKey + '/' + api_calls[ext],
                        success: addData
                    });
                }
            } else {
                for (idx = 0; idx < times.length; idx++) {
                    counts = times[idx];
                    if (counts) {
                        firstReview = counts[4] - start_time;
                        break;
                    }
                }
                setTimeout(initCanvas, 0);
            }
            $('a.help').click(function () {
                alert('Reviews Timeline - Displays your upcoming reviews\nY-axis: Number of reviews\nX-axis: Time (scale set by the slider)\n\nThe number in the upper left shows the maximum number of reviews in a single bar. White-backed bars indicate that review group contains radicals/kanji necessary for advancing your current level.');
            });
            $('#g-range').change(function () {
                gHours = $(this).val();
                if (gHours < 6) {
                    gHours = pastReviews ? 0 : 3;
                }
                $('#g-timeframe').text(gHours === 0 ? 'right now' : 'in ' + gHours + ' hours');
                drawCanvas(true);
            });
        } else {
            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';
        }
    }
    // 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;
    }
    styleCss = '\n' +
        '#graph-tip {\n' +
        '    padding: 2px 8px;\n' +
        '    position: absolute;\n' +
        '    color: #eeeeee;\n' +
        '    background-color: rgba(0,0,0,0.5);\n' +
        '    border-radius: 4px;\n' +
        '    pointer-events: none;\n' +
        '    font-weight: bold;\n' +
        '}\n' +
        'section#r-timeline {\n' +
        '    overflow: hidden;\n' +
        '    margin-bottom: 0px;\n' +
        '    height: ' + (options.enable_arrows ? '130' : '117') + 'px;\n' +
        '}\n' +
        'form#graph-form {\n' +
        '    float: right;\n' +
        '    margin-bottom: 0px;\n' +
        '    min-width: 50%;\n' +
        '    text-align: right;\n' +
        '}\n' +
        'section#r-timeline 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.help {\n' +
        '    font-weight: bold;\n' +
        '    color: rgba(0, 0, 0, 0.1);\n' +
        '    font-size: 1.2em;\n' +
        '    line-height: 0px;\n' +
        '}\n' +
        'a.help:hover {\n' +
        '    text-decoration: none;\n' +
        '    cursor: help;\n' +
        '    color: rgba(0, 0, 0, 0.5);\n' +
        '}\n' +
        '@media (max-width: 767px) {\n' +
        '    section#r-timeline h4 {\n' +
        '        display: none;\n' +
        '    }\n' +
        '}\n' +
        '.dashboard section.review-status ul li time {\n' +
        '    white-space: nowrap;\n' +
        '    overflow-x: hidden;\n' +
        '    height: 1.5em;\n' +
        '    margin-bottom: 0;\n' +
        '}\n';
    if (document.location.pathname === "/account") {
        (function () {
            var apiKey, alreadySaved;
            apiKey = $('input[placeholder="Key has not been generated"]').val();
            if (apiKey) {
                alreadySaved = localStorage.getItem('apiKey');
                localStorage.setItem('apiKey', apiKey);
                console.log('WaniKani Timeline Updated API Key: ' + apiKey);
                if (!alreadySaved) {
                    document.location.pathname = '/dashboard';
                }
            }
        }());
    } else {
        page_width = $('.span12 header').width();
        if (page_width) {
            tRes = Math.round(1 / (page_width / 1170 / 15));
            insertTimeline();
        }
        addStyle(styleCss);
    }
    console.log('WaniKani Timeline: script load end');
}());